diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index bd45753d010..9b76f3550fd 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.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@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e10bc607258..af0bdc5c2df 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,10 +37,10 @@ on: type: boolean env: - CACHE_VERSION: 12 + CACHE_VERSION: 2 UV_CACHE_VERSION: 1 - MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.5" + MYPY_CACHE_VERSION: 1 + HA_SHORT_VERSION: "2025.6" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version @@ -259,7 +259,7 @@ jobs: with: path: venv key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' @@ -276,7 +276,7 @@ jobs: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - name: Install pre-commit dependencies if: steps.cache-precommit.outputs.cache-hit != 'true' @@ -306,7 +306,7 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit @@ -315,7 +315,7 @@ jobs: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - name: Run ruff-format run: | @@ -346,7 +346,7 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit @@ -355,7 +355,7 @@ jobs: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - name: Run ruff run: | @@ -386,7 +386,7 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit @@ -395,7 +395,7 @@ jobs: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - name: Register yamllint problem matcher @@ -501,7 +501,7 @@ jobs: with: path: venv key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' @@ -509,10 +509,10 @@ jobs: with: path: ${{ env.UV_CACHE_DIR }} key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ steps.generate-uv-key.outputs.key }} restore-keys: | - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-uv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{ env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{ env.HA_SHORT_VERSION }}- - name: Install additional OS dependencies @@ -598,7 +598,7 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Run hassfest run: | @@ -631,7 +631,7 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Run gen_requirements_all.py run: | @@ -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.6.0 + uses: actions/dependency-review-action@v4.7.1 with: license-check: false # We use our own license audit checks @@ -688,7 +688,7 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Extract license data run: | @@ -731,7 +731,7 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register pylint problem matcher run: | @@ -778,7 +778,7 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register pylint problem matcher run: | @@ -830,17 +830,17 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache uses: actions/cache@v4.2.3 with: path: .mypy_cache key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ steps.generate-mypy-key.outputs.key }} restore-keys: | - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-mypy-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-mypy-${{ env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{ env.HA_SHORT_VERSION }}- - name: Register mypy problem matcher @@ -900,7 +900,7 @@ jobs: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Run split_tests.py run: | @@ -944,7 +944,8 @@ jobs: bluez \ ffmpeg \ libturbojpeg \ - libgammu-dev + libgammu-dev \ + libxml2-utils - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} @@ -959,7 +960,8 @@ jobs: with: path: venv fail-on-cache-miss: true - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register Python problem matcher run: | @@ -1019,6 +1021,12 @@ jobs: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true + - name: Beautify test results + # For easier identification of parsing errors + if: needs.info.outputs.skip_coverage != 'true' + run: | + xmllint --format "junit.xml" > "junit.xml-tmp" + mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.2 @@ -1069,7 +1077,8 @@ jobs: bluez \ ffmpeg \ libturbojpeg \ - libmariadb-dev-compat + libmariadb-dev-compat \ + libxml2-utils - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} @@ -1084,7 +1093,8 @@ jobs: with: path: venv fail-on-cache-miss: true - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register Python problem matcher run: | @@ -1152,6 +1162,12 @@ jobs: steps.pytest-partial.outputs.mariadb }} path: coverage.xml overwrite: true + - name: Beautify test results + # For easier identification of parsing errors + if: needs.info.outputs.skip_coverage != 'true' + run: | + xmllint --format "junit.xml" > "junit.xml-tmp" + mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.2 @@ -1200,7 +1216,8 @@ jobs: sudo apt-get -y install \ bluez \ ffmpeg \ - libturbojpeg + libturbojpeg \ + libxml2-utils sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y sudo apt-get -y install \ postgresql-server-dev-14 @@ -1218,7 +1235,8 @@ jobs: with: path: venv fail-on-cache-miss: true - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register Python problem matcher run: | @@ -1287,6 +1305,12 @@ jobs: steps.pytest-partial.outputs.postgresql }} path: coverage.xml overwrite: true + - name: Beautify test results + # For easier identification of parsing errors + if: needs.info.outputs.skip_coverage != 'true' + run: | + xmllint --format "junit.xml" > "junit.xml-tmp" + mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.2 @@ -1317,7 +1341,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: fail_ci_if_error: true flags: full-suite @@ -1354,7 +1378,8 @@ jobs: bluez \ ffmpeg \ libturbojpeg \ - libgammu-dev + libgammu-dev \ + libxml2-utils - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} @@ -1369,7 +1394,8 @@ jobs: with: path: venv fail-on-cache-miss: true - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register Python problem matcher run: | @@ -1432,6 +1458,12 @@ jobs: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true + - name: Beautify test results + # For easier identification of parsing errors + if: needs.info.outputs.skip_coverage != 'true' + run: | + xmllint --format "junit.xml" > "junit.xml-tmp" + mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.2 @@ -1459,7 +1491,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c6181121043..818aa813208 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.16 + uses: github/codeql-action/init@v3.28.18 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.16 + uses: github/codeql-action/analyze@v3.28.18 with: category: "/language:python" diff --git a/.strict-typing b/.strict-typing index 9752ae30fff..b34cbfa5fca 100644 --- a/.strict-typing +++ b/.strict-typing @@ -65,6 +65,7 @@ homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* homeassistant.components.alexa.* +homeassistant.components.alexa_devices.* homeassistant.components.alpha_vantage.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* @@ -270,6 +271,7 @@ homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.imap.* homeassistant.components.imgw_pib.* +homeassistant.components.immich.* homeassistant.components.incomfort.* homeassistant.components.input_button.* homeassistant.components.input_select.* @@ -332,6 +334,7 @@ homeassistant.components.media_player.* homeassistant.components.media_source.* homeassistant.components.met_eireann.* homeassistant.components.metoffice.* +homeassistant.components.miele.* homeassistant.components.mikrotik.* homeassistant.components.min_max.* homeassistant.components.minecraft_server.* @@ -384,6 +387,7 @@ homeassistant.components.overseerr.* homeassistant.components.p1_monitor.* homeassistant.components.pandora.* homeassistant.components.panel_custom.* +homeassistant.components.paperless_ngx.* homeassistant.components.peblar.* homeassistant.components.peco.* homeassistant.components.pegel_online.* @@ -433,7 +437,6 @@ homeassistant.components.roku.* homeassistant.components.romy.* homeassistant.components.rpi_power.* homeassistant.components.rss_feed_template.* -homeassistant.components.rtsp_to_webrtc.* homeassistant.components.russound_rio.* homeassistant.components.ruuvi_gateway.* homeassistant.components.ruuvitag_ble.* diff --git a/CODEOWNERS b/CODEOWNERS index 8fb77243bd1..b447c878128 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -46,8 +46,8 @@ build.json @home-assistant/supervisor /tests/components/accuweather/ @bieniu /homeassistant/components/acmeda/ @atmurray /tests/components/acmeda/ @atmurray -/homeassistant/components/adax/ @danielhiversen -/tests/components/adax/ @danielhiversen +/homeassistant/components/adax/ @danielhiversen @lazytarget +/tests/components/adax/ @danielhiversen @lazytarget /homeassistant/components/adguard/ @frenck /tests/components/adguard/ @frenck /homeassistant/components/ads/ @mrpasztoradam @@ -89,6 +89,8 @@ build.json @home-assistant/supervisor /tests/components/alert/ @home-assistant/core @frenck /homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh /tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh +/homeassistant/components/alexa_devices/ @chemelli74 +/tests/components/alexa_devices/ @chemelli74 /homeassistant/components/amazon_polly/ @jschlyter /homeassistant/components/amberelectric/ @madpilot /tests/components/amberelectric/ @madpilot @@ -202,8 +204,8 @@ build.json @home-assistant/supervisor /tests/components/blebox/ @bbx-a @swistakm /homeassistant/components/blink/ @fronzbot @mkmer /tests/components/blink/ @fronzbot @mkmer -/homeassistant/components/blue_current/ @Floris272 @gleeuwen -/tests/components/blue_current/ @Floris272 @gleeuwen +/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 +/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 /homeassistant/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core @@ -303,6 +305,7 @@ build.json @home-assistant/supervisor /homeassistant/components/crownstone/ @Crownstone @RicArch97 /tests/components/crownstone/ @Crownstone @RicArch97 /homeassistant/components/cups/ @fabaff +/tests/components/cups/ @fabaff /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike /homeassistant/components/date/ @home-assistant/core @@ -455,8 +458,8 @@ build.json @home-assistant/supervisor /tests/components/evil_genius_labs/ @balloob /homeassistant/components/evohome/ @zxdavb /tests/components/evohome/ @zxdavb -/homeassistant/components/ezviz/ @RenierM26 @baqs -/tests/components/ezviz/ @RenierM26 @baqs +/homeassistant/components/ezviz/ @RenierM26 +/tests/components/ezviz/ @RenierM26 /homeassistant/components/faa_delays/ @ntilley905 /tests/components/faa_delays/ @ntilley905 /homeassistant/components/fan/ @home-assistant/core @@ -710,6 +713,8 @@ build.json @home-assistant/supervisor /tests/components/imeon_inverter/ @Imeon-Energy /homeassistant/components/imgw_pib/ @bieniu /tests/components/imgw_pib/ @bieniu +/homeassistant/components/immich/ @mib1185 +/tests/components/immich/ @mib1185 /homeassistant/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @jbouwh @@ -1111,8 +1116,8 @@ build.json @home-assistant/supervisor /tests/components/opentherm_gw/ @mvn23 /homeassistant/components/openuv/ @bachya /tests/components/openuv/ @bachya -/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi -/tests/components/openweathermap/ @fabaff @freekode @nzapponi +/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck +/tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck /homeassistant/components/opnsense/ @mtreinish /tests/components/opnsense/ @mtreinish /homeassistant/components/opower/ @tronikos @@ -1138,6 +1143,8 @@ build.json @home-assistant/supervisor /tests/components/palazzetti/ @dotvav /homeassistant/components/panel_custom/ @home-assistant/frontend /tests/components/panel_custom/ @home-assistant/frontend +/homeassistant/components/paperless_ngx/ @fvgarrel +/tests/components/paperless_ngx/ @fvgarrel /homeassistant/components/peblar/ @frenck /tests/components/peblar/ @frenck /homeassistant/components/peco/ @IceBotYT @@ -1176,6 +1183,8 @@ build.json @home-assistant/supervisor /tests/components/powerwall/ @bdraco @jrester @daniel-simpson /homeassistant/components/private_ble_device/ @Jc2k /tests/components/private_ble_device/ @Jc2k +/homeassistant/components/probe_plus/ @pantherale0 +/tests/components/probe_plus/ @pantherale0 /homeassistant/components/profiler/ @bdraco /tests/components/profiler/ @bdraco /homeassistant/components/progettihwsw/ @ardaseremet @@ -1222,6 +1231,7 @@ build.json @home-assistant/supervisor /homeassistant/components/qnap_qsw/ @Noltari /tests/components/qnap_qsw/ @Noltari /homeassistant/components/quantum_gateway/ @cisasteelersfan +/tests/components/quantum_gateway/ @cisasteelersfan /homeassistant/components/qvr_pro/ @oblogic7 /homeassistant/components/qwikswitch/ @kellerza /tests/components/qwikswitch/ @kellerza @@ -1307,8 +1317,6 @@ build.json @home-assistant/supervisor /tests/components/rpi_power/ @shenxn @swetoast /homeassistant/components/rss_feed_template/ @home-assistant/core /tests/components/rss_feed_template/ @home-assistant/core -/homeassistant/components/rtsp_to_webrtc/ @allenporter -/tests/components/rtsp_to_webrtc/ @allenporter /homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/russound_rio/ @noahhusby @@ -1412,6 +1420,8 @@ build.json @home-assistant/supervisor /tests/components/sma/ @kellerza @rklomp @erwindouna /homeassistant/components/smappee/ @bsmappee /tests/components/smappee/ @bsmappee +/homeassistant/components/smarla/ @explicatis @rlint-explicatis +/tests/components/smarla/ @explicatis @rlint-explicatis /homeassistant/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler /homeassistant/components/smartthings/ @joostlek @@ -1486,8 +1496,8 @@ build.json @home-assistant/supervisor /tests/components/subaru/ @G-Two /homeassistant/components/suez_water/ @ooii @jb101010-2 /tests/components/suez_water/ @ooii @jb101010-2 -/homeassistant/components/sun/ @Swamp-Ig -/tests/components/sun/ @Swamp-Ig +/homeassistant/components/sun/ @home-assistant/core +/tests/components/sun/ @home-assistant/core /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen @@ -1500,8 +1510,8 @@ build.json @home-assistant/supervisor /tests/components/switch_as_x/ @home-assistant/core /homeassistant/components/switchbee/ @jafar-atili /tests/components/switchbee/ @jafar-atili -/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski -/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski +/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang +/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang /homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur /tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur /homeassistant/components/switcher_kis/ @thecode @YogevBokobza @@ -1541,8 +1551,8 @@ build.json @home-assistant/supervisor /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike -/homeassistant/components/template/ @Petro31 @PhracturedBlue @home-assistant/core -/tests/components/template/ @Petro31 @PhracturedBlue @home-assistant/core +/homeassistant/components/template/ @Petro31 @home-assistant/core +/tests/components/template/ @Petro31 @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_wall_connector/ @einarhauks @@ -1796,6 +1806,8 @@ build.json @home-assistant/supervisor /tests/components/zeversolar/ @kvanzuijlen /homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES +/homeassistant/components/zimi/ @markhannon +/tests/components/zimi/ @markhannon /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core diff --git a/homeassistant/brands/amazon.json b/homeassistant/brands/amazon.json index 624a8a17b7d..126b69c848d 100644 --- a/homeassistant/brands/amazon.json +++ b/homeassistant/brands/amazon.json @@ -3,6 +3,7 @@ "name": "Amazon", "integrations": [ "alexa", + "alexa_devices", "amazon_polly", "aws", "aws_s3", diff --git a/homeassistant/brands/shelly.json b/homeassistant/brands/shelly.json new file mode 100644 index 00000000000..94d683157ee --- /dev/null +++ b/homeassistant/brands/shelly.json @@ -0,0 +1,6 @@ +{ + "domain": "shelly", + "name": "shelly", + "integrations": ["shelly"], + "iot_standards": ["zwave"] +} diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py index 5024507a7d3..785906ebf2a 100644 --- a/homeassistant/components/acmeda/config_flow.py +++ b/homeassistant/components/acmeda/config_flow.py @@ -40,9 +40,10 @@ class AcmedaFlowHandler(ConfigFlow, domain=DOMAIN): entry.unique_id for entry in self._async_current_entries() } + hubs: list[aiopulse.Hub] = [] with suppress(TimeoutError): async with timeout(5): - hubs: list[aiopulse.Hub] = [ + hubs = [ hub async for hub in aiopulse.Hub.discover() if hub.id not in already_configured diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index 2742180333b..efbc611f9d3 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -1,7 +1,7 @@ { "domain": "adax", "name": "Adax", - "codeowners": ["@danielhiversen"], + "codeowners": ["@danielhiversen", "@lazytarget"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/adax", "iot_class": "local_polling", diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index ffd502663b0..9708adbc1f7 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry -from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN +from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN from .entity import AdvantageAirEntity, AdvantageAirThingEntity from .models import AdvantageAirData @@ -52,8 +52,8 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): self._id: str = light["id"] self._attr_unique_id += f"-{self._id}" self._attr_device_info = DeviceInfo( - identifiers={(ADVANTAGE_AIR_DOMAIN, self._attr_unique_id)}, - via_device=(ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"]), + identifiers={(DOMAIN, self._attr_unique_id)}, + via_device=(DOMAIN, self.coordinator.data["system"]["rid"]), manufacturer="Advantage Air", model=light.get("moduleType"), name=light["name"], diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py index 92a162303dd..68df31142e3 100644 --- a/homeassistant/components/advantage_air/update.py +++ b/homeassistant/components/advantage_air/update.py @@ -6,7 +6,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry -from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN +from .const import DOMAIN from .entity import AdvantageAirEntity from .models import AdvantageAirData @@ -32,9 +32,7 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity): """Initialize the Advantage Air App.""" super().__init__(instance) self._attr_device_info = DeviceInfo( - identifiers={ - (ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"]) - }, + identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])}, manufacturer="Advantage Air", model=self.coordinator.data["system"]["sysType"], name=self.coordinator.data["system"]["name"], diff --git a/homeassistant/components/agent_dvr/__init__.py b/homeassistant/components/agent_dvr/__init__.py index 2cb32b6c80e..d504568869c 100644 --- a/homeassistant/components/agent_dvr/__init__.py +++ b/homeassistant/components/agent_dvr/__init__.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN as AGENT_DOMAIN, SERVER_URL +from .const import DOMAIN, SERVER_URL ATTRIBUTION = "ispyconnect.com" DEFAULT_BRAND = "Agent DVR by ispyconnect.com" @@ -46,7 +46,7 @@ async def async_setup_entry( device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(AGENT_DOMAIN, agent_client.unique)}, + identifiers={(DOMAIN, agent_client.unique)}, manufacturer="iSpyConnect", name=f"Agent {agent_client.name}", model="Agent DVR", diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 1ac808c87ad..0d9267e7739 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -12,7 +12,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AgentDVRConfigEntry -from .const import DOMAIN as AGENT_DOMAIN +from .const import DOMAIN CONF_HOME_MODE_NAME = "home" CONF_AWAY_MODE_NAME = "away" @@ -47,7 +47,7 @@ class AgentBaseStation(AlarmControlPanelEntity): self._client = client self._attr_unique_id = f"{client.unique}_CP" self._attr_device_info = DeviceInfo( - identifiers={(AGENT_DOMAIN, client.unique)}, + identifiers={(DOMAIN, client.unique)}, name=f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}", manufacturer="Agent", model=CONST_ALARM_CONTROL_PANEL_NAME, diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index 7484c7e85a9..9ee103b3a90 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -51,9 +51,16 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): async def _async_setup(self) -> None: """Set up the coordinator.""" - self._current_version = ( - await self.client.get_current_measures() - ).firmware_version + try: + self._current_version = ( + await self.client.get_current_measures() + ).firmware_version + except AirGradientError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(error)}, + ) from error async def _async_update_data(self) -> AirGradientData: try: diff --git a/homeassistant/components/airgradient/entity.py b/homeassistant/components/airgradient/entity.py index 51256051259..1d5430e5403 100644 --- a/homeassistant/components/airgradient/entity.py +++ b/homeassistant/components/airgradient/entity.py @@ -6,6 +6,7 @@ from typing import Any, Concatenate from airgradient import AirGradientConnectionError, AirGradientError, get_model_name from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -29,6 +30,7 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]): model_id=measures.model, serial_number=coordinator.serial_number, sw_version=measures.firmware_version, + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.serial_number)}, ) diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json index 67057ff09f5..5204d7a4ba8 100644 --- a/homeassistant/components/airthings/manifest.json +++ b/homeassistant/components/airthings/manifest.json @@ -3,6 +3,19 @@ "name": "Airthings", "codeowners": ["@danielhiversen", "@LaStrada"], "config_flow": true, + "dhcp": [ + { + "hostname": "airthings-view" + }, + { + "hostname": "airthings-hub", + "macaddress": "D0141190*" + }, + { + "hostname": "airthings-hub", + "macaddress": "70B3D52A0*" + } + ], "documentation": "https://www.home-assistant.io/integrations/airthings", "iot_class": "cloud_polling", "loggers": ["airthings"], diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index a0d9c97c8c8..f2bf8e071f7 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory, @@ -78,6 +79,12 @@ SENSORS: dict[str, SensorEntityDescription] = { translation_key="light", state_class=SensorStateClass.MEASUREMENT, ), + "lux": SensorEntityDescription( + key="lux", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + ), "virusRisk": SensorEntityDescription( key="virusRisk", translation_key="virus_risk", diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 6d393ed0c99..3cb6a78128b 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -142,7 +142,7 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode] @property - def hvac_modes(self): + def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number) modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes] @@ -226,12 +226,12 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): return super()._handle_coordinator_update() @property - def min_temp(self): + def min_temp(self) -> float: """Return Minimum Temperature for AC of this group.""" return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint @property - def max_temp(self): + def max_temp(self) -> float: """Return Max Temperature for AC of this group.""" return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint diff --git a/homeassistant/components/alarmdecoder/strings.json b/homeassistant/components/alarmdecoder/strings.json index ccf1d965855..3bc8363b90d 100644 --- a/homeassistant/components/alarmdecoder/strings.json +++ b/homeassistant/components/alarmdecoder/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Choose AlarmDecoder Protocol", + "title": "Choose AlarmDecoder protocol", "data": { "protocol": "Protocol" } @@ -12,8 +12,8 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", - "device_baudrate": "Device Baud Rate", - "device_path": "Device Path" + "device_baudrate": "Device baud rate", + "device_path": "Device path" }, "data_description": { "host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.", @@ -44,36 +44,36 @@ "arm_settings": { "title": "[%key:component::alarmdecoder::options::step::init::title%]", "data": { - "auto_bypass": "Auto Bypass on Arm", - "code_arm_required": "Code Required for Arming", - "alt_night_mode": "Alternative Night Mode" + "auto_bypass": "Auto-bypass on arm", + "code_arm_required": "Code required for arming", + "alt_night_mode": "Alternative night mode" } }, "zone_select": { "title": "[%key:component::alarmdecoder::options::step::init::title%]", "description": "Enter the zone number you'd like to to add, edit, or remove.", "data": { - "zone_number": "Zone Number" + "zone_number": "Zone number" } }, "zone_details": { "title": "[%key:component::alarmdecoder::options::step::init::title%]", - "description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave Zone Name blank.", + "description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave 'Zone name' blank.", "data": { - "zone_name": "Zone Name", - "zone_type": "Zone Type", - "zone_rfid": "RF Serial", - "zone_loop": "RF Loop", - "zone_relayaddr": "Relay Address", - "zone_relaychan": "Relay Channel" + "zone_name": "Zone name", + "zone_type": "Zone type", + "zone_rfid": "RF serial", + "zone_loop": "RF loop", + "zone_relayaddr": "Relay address", + "zone_relaychan": "Relay channel" } } }, "error": { - "relay_inclusive": "Relay Address and Relay Channel are codependent and must be included together.", + "relay_inclusive": "'Relay address' and 'Relay channel' are codependent and must be included together.", "int": "The field below must be an integer.", - "loop_rfid": "RF Loop cannot be used without RF Serial.", - "loop_range": "RF Loop must be an integer between 1 and 4." + "loop_rfid": "'RF loop' cannot be used without 'RF serial'.", + "loop_range": "'RF loop' must be an integer between 1 and 4." } }, "services": { diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py new file mode 100644 index 00000000000..7a4139a65da --- /dev/null +++ b/homeassistant/components/alexa_devices/__init__.py @@ -0,0 +1,32 @@ +"""Alexa Devices integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.NOTIFY, + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: + """Set up Alexa Devices platform.""" + + coordinator = AmazonDevicesCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: + """Unload a config entry.""" + await entry.runtime_data.api.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py new file mode 100644 index 00000000000..16cf73aee9f --- /dev/null +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -0,0 +1,74 @@ +"""Support for binary sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from aioamazondevices.api import AmazonDevice + +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 .coordinator import AmazonConfigEntry +from .entity import AmazonEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): + """Alexa Devices binary sensor entity description.""" + + is_on_fn: Callable[[AmazonDevice], bool] + + +BINARY_SENSORS: Final = ( + AmazonBinarySensorEntityDescription( + key="online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda _device: _device.online, + ), + AmazonBinarySensorEntityDescription( + key="bluetooth", + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="bluetooth", + is_on_fn=lambda _device: _device.bluetooth_state, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AmazonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Alexa Devices binary sensors based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in BINARY_SENSORS + for serial_num in coordinator.data + ) + + +class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): + """Binary sensor device.""" + + entity_description: AmazonBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return True if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.device) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py new file mode 100644 index 00000000000..5add7ceb711 --- /dev/null +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Alexa Devices integration.""" + +from __future__ import annotations + +from typing import Any + +from aioamazondevices.api import AmazonEchoApi +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import CountrySelector + +from .const import CONF_LOGIN_DATA, DOMAIN + + +class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Alexa Devices.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input: + client = AmazonEchoApi( + user_input[CONF_COUNTRY], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + try: + data = await client.login_mode_interactive(user_input[CONF_CODE]) + except CannotConnect: + errors["base"] = "cannot_connect" + except CannotAuthenticate: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id(data["customer_info"]["user_id"]) + self._abort_if_unique_id_configured() + user_input.pop(CONF_CODE) + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input | {CONF_LOGIN_DATA: data}, + ) + finally: + await client.close() + + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=vol.Schema( + { + vol.Required( + CONF_COUNTRY, default=self.hass.config.country + ): CountrySelector(), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CODE): cv.string, + } + ), + ) diff --git a/homeassistant/components/alexa_devices/const.py b/homeassistant/components/alexa_devices/const.py new file mode 100644 index 00000000000..ca0290a10bc --- /dev/null +++ b/homeassistant/components/alexa_devices/const.py @@ -0,0 +1,8 @@ +"""Alexa Devices constants.""" + +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "alexa_devices" +CONF_LOGIN_DATA = "login_data" diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py new file mode 100644 index 00000000000..8e58441d46c --- /dev/null +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -0,0 +1,58 @@ +"""Support for Alexa Devices.""" + +from datetime import timedelta + +from aioamazondevices.api import AmazonDevice, AmazonEchoApi +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import _LOGGER, CONF_LOGIN_DATA + +SCAN_INTERVAL = 30 + +type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator] + + +class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): + """Base coordinator for Alexa Devices.""" + + config_entry: AmazonConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: AmazonConfigEntry, + ) -> None: + """Initialize the scanner.""" + super().__init__( + hass, + _LOGGER, + name=entry.title, + config_entry=entry, + update_interval=timedelta(seconds=SCAN_INTERVAL), + ) + self.api = AmazonEchoApi( + entry.data[CONF_COUNTRY], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_LOGIN_DATA], + ) + + async def _async_update_data(self) -> dict[str, AmazonDevice]: + """Update device data.""" + try: + await self.api.login_mode_stored_data() + return await self.api.get_devices_data() + except (CannotConnect, CannotRetrieveData) as err: + raise UpdateFailed(f"Error occurred while updating {self.name}") from err + except CannotAuthenticate as err: + raise ConfigEntryError("Could not authenticate") from err diff --git a/homeassistant/components/alexa_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py new file mode 100644 index 00000000000..0c4cb794416 --- /dev/null +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -0,0 +1,66 @@ +"""Diagnostics support for Alexa Devices integration.""" + +from __future__ import annotations + +from typing import Any + +from aioamazondevices.api import AmazonDevice + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .coordinator import AmazonConfigEntry + +TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AmazonConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data + + devices: list[dict[str, dict[str, Any]]] = [ + build_device_data(device) for device in coordinator.data.values() + ] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "device_info": { + "last_update success": coordinator.last_update_success, + "last_exception": repr(coordinator.last_exception), + "devices": devices, + }, + } + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: AmazonConfigEntry, device_entry: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + + coordinator = entry.runtime_data + + assert device_entry.serial_number + + return build_device_data(coordinator.data[device_entry.serial_number]) + + +def build_device_data(device: AmazonDevice) -> dict[str, Any]: + """Build device data for diagnostics.""" + return { + "account name": device.account_name, + "capabilities": device.capabilities, + "device family": device.device_family, + "device type": device.device_type, + "device cluster members": device.device_cluster_members, + "online": device.online, + "serial number": device.serial_number, + "software version": device.software_version, + "do not disturb": device.do_not_disturb, + "response style": device.response_style, + "bluetooth state": device.bluetooth_state, + } diff --git a/homeassistant/components/alexa_devices/entity.py b/homeassistant/components/alexa_devices/entity.py new file mode 100644 index 00000000000..f539079602f --- /dev/null +++ b/homeassistant/components/alexa_devices/entity.py @@ -0,0 +1,57 @@ +"""Defines a base Alexa Devices entity.""" + +from aioamazondevices.api import AmazonDevice +from aioamazondevices.const import SPEAKER_GROUP_MODEL + +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 AmazonDevicesCoordinator + + +class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]): + """Defines a base Alexa Devices entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AmazonDevicesCoordinator, + serial_num: str, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._serial_num = serial_num + model_details = coordinator.api.get_model_details(self.device) or {} + model = model_details.get("model") + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_num)}, + name=self.device.account_name, + model=model, + model_id=self.device.device_type, + manufacturer=model_details.get("manufacturer", "Amazon"), + hw_version=model_details.get("hw_version"), + sw_version=( + self.device.software_version if model != SPEAKER_GROUP_MODEL else None + ), + serial_number=serial_num if model != SPEAKER_GROUP_MODEL else None, + ) + self.entity_description = description + self._attr_unique_id = f"{serial_num}-{description.key}" + + @property + def device(self) -> AmazonDevice: + """Return the device.""" + return self.coordinator.data[self._serial_num] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self._serial_num in self.coordinator.data + and self.device.online + ) diff --git a/homeassistant/components/alexa_devices/icons.json b/homeassistant/components/alexa_devices/icons.json new file mode 100644 index 00000000000..e3b20eb2c4a --- /dev/null +++ b/homeassistant/components/alexa_devices/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "bluetooth": { + "default": "mdi:bluetooth", + "state": { + "off": "mdi:bluetooth-off" + } + } + } + } +} diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json new file mode 100644 index 00000000000..2a9e88cfd85 --- /dev/null +++ b/homeassistant/components/alexa_devices/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "alexa_devices", + "name": "Alexa Devices", + "codeowners": ["@chemelli74"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/alexa_devices", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["aioamazondevices"], + "quality_scale": "bronze", + "requirements": ["aioamazondevices==3.0.6"] +} diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py new file mode 100644 index 00000000000..ff0cd4e59ea --- /dev/null +++ b/homeassistant/components/alexa_devices/notify.py @@ -0,0 +1,74 @@ +"""Support for notification entity.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Final + +from aioamazondevices.api import AmazonDevice, AmazonEchoApi + +from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import AmazonConfigEntry +from .entity import AmazonEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class AmazonNotifyEntityDescription(NotifyEntityDescription): + """Alexa Devices notify entity description.""" + + method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]] + subkey: str + + +NOTIFY: Final = ( + AmazonNotifyEntityDescription( + key="speak", + translation_key="speak", + subkey="AUDIO_PLAYER", + method=lambda api, device, message: api.call_alexa_speak(device, message), + ), + AmazonNotifyEntityDescription( + key="announce", + translation_key="announce", + subkey="AUDIO_PLAYER", + method=lambda api, device, message: api.call_alexa_announcement( + device, message + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AmazonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Alexa Devices notification entity based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + AmazonNotifyEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in NOTIFY + for serial_num in coordinator.data + if sensor_desc.subkey in coordinator.data[serial_num].capabilities + ) + + +class AmazonNotifyEntity(AmazonEntity, NotifyEntity): + """Binary sensor notify platform.""" + + entity_description: AmazonNotifyEntityDescription + + async def async_send_message( + self, message: str, title: str | None = None, **kwargs: Any + ) -> None: + """Send a message.""" + + await self.entity_description.method(self.coordinator.api, self.device, message) diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml new file mode 100644 index 00000000000..881a02bc6d3 --- /dev/null +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -0,0 +1,76 @@ +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: entities do 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: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: + status: todo + comment: all tests missing + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Network information not relevant + discovery: + status: exempt + comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: todo + comment: automate the cleanup process + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json new file mode 100644 index 00000000000..9d615b248ed --- /dev/null +++ b/homeassistant/components/alexa_devices/strings.json @@ -0,0 +1,60 @@ +{ + "common": { + "data_country": "Country code", + "data_code": "One-time password (OTP code)", + "data_description_country": "The country of your Amazon account.", + "data_description_username": "The email address of your Amazon account.", + "data_description_password": "The password of your Amazon account.", + "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported." + }, + "config": { + "flow_title": "{username}", + "step": { + "user": { + "data": { + "country": "[%key:component::alexa_devices::common::data_country%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "code": "[%key:component::alexa_devices::common::data_description_code%]" + }, + "data_description": { + "country": "[%key:component::alexa_devices::common::data_description_country%]", + "username": "[%key:component::alexa_devices::common::data_description_username%]", + "password": "[%key:component::alexa_devices::common::data_description_password%]", + "code": "[%key:component::alexa_devices::common::data_description_code%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "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%]" + }, + "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%]" + } + }, + "entity": { + "binary_sensor": { + "bluetooth": { + "name": "Bluetooth" + } + }, + "notify": { + "speak": { + "name": "Speak" + }, + "announce": { + "name": "Announce" + } + }, + "switch": { + "do_not_disturb": { + "name": "Do not disturb" + } + } + } +} diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py new file mode 100644 index 00000000000..b8f78134feb --- /dev/null +++ b/homeassistant/components/alexa_devices/switch.py @@ -0,0 +1,84 @@ +"""Support for switches.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final + +from aioamazondevices.api import AmazonDevice + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import AmazonConfigEntry +from .entity import AmazonEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class AmazonSwitchEntityDescription(SwitchEntityDescription): + """Alexa Devices switch entity description.""" + + is_on_fn: Callable[[AmazonDevice], bool] + subkey: str + method: str + + +SWITCHES: Final = ( + AmazonSwitchEntityDescription( + key="do_not_disturb", + subkey="AUDIO_PLAYER", + translation_key="do_not_disturb", + is_on_fn=lambda _device: _device.do_not_disturb, + method="set_do_not_disturb", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AmazonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Alexa Devices switches based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + AmazonSwitchEntity(coordinator, serial_num, switch_desc) + for switch_desc in SWITCHES + for serial_num in coordinator.data + if switch_desc.subkey in coordinator.data[serial_num].capabilities + ) + + +class AmazonSwitchEntity(AmazonEntity, SwitchEntity): + """Switch device.""" + + entity_description: AmazonSwitchEntityDescription + + async def _switch_set_state(self, state: bool) -> None: + """Set desired switch state.""" + method = getattr(self.coordinator.api, self.entity_description.method) + + if TYPE_CHECKING: + assert method is not None + + await method(self.device, state) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._switch_set_state(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._switch_set_state(False) + + @property + def is_on(self) -> bool: + """Return True if switch is on.""" + return self.entity_description.is_on_fn(self.device) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 89cc0fc3965..7896f7eefc8 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.2.1"], + "requirements": ["androidtvremote2==0.2.2"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 106cac3a63d..c82b815e27a 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -51,6 +51,10 @@ "app_id": "Application ID", "app_icon": "Application icon", "app_delete": "Check to delete this application" + }, + "data_description": { + "app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android", + "app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename" } } } diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 38e4270e6e1..69789b9a64a 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -17,4 +17,11 @@ CONF_THINKING_BUDGET = "thinking_budget" RECOMMENDED_THINKING_BUDGET = 0 MIN_THINKING_BUDGET = 1024 -THINKING_MODELS = ["claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest"] +THINKING_MODELS = [ + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-latest", + "claude-opus-4-20250514", + "claude-opus-4-0", + "claude-sonnet-4-20250514", + "claude-sonnet-4-0", +] diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 7e1fda467a8..3e79be0b169 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -294,6 +294,8 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have elif isinstance(response, RawMessageDeltaEvent): if (usage := response.usage) is not None: chat_log.async_trace(_create_token_stats(input_usage, usage)) + if response.delta.stop_reason == "refusal": + raise HomeAssistantError("Potential policy violation detected") elif isinstance(response, RawMessageStopEvent): if current_message is not None: messages.append(current_message) @@ -326,6 +328,7 @@ class AnthropicConversationEntity( _attr_has_entity_name = True _attr_name = None + _attr_supports_streaming = True def __init__(self, entry: AnthropicConfigEntry) -> None: """Initialize the agent.""" diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 797a7299d16..6a8f1e5e54c 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["anthropic==0.47.2"] + "requirements": ["anthropic==0.52.0"] } diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index b65c9c33265..bd26aa0a2d4 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -46,11 +46,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=_SCHEMA) host, port = user_input[CONF_HOST], user_input[CONF_PORT] - - # Abort if an entry with same host and port is present. self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) - - # Test the connection to the host and get the current status for serial number. try: async with asyncio.timeout(CONNECTION_TIMEOUT): data = APCUPSdData(await aioapcaccess.request_status(host, port)) @@ -67,3 +63,30 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): title = data.name or data.model or data.serial_no or "APC UPS" return self.async_create_entry(title=title, data=user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry.""" + + if user_input is None: + return self.async_show_form(step_id="reconfigure", data_schema=_SCHEMA) + + host, port = user_input[CONF_HOST], user_input[CONF_PORT] + self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) + try: + async with asyncio.timeout(CONNECTION_TIMEOUT): + data = APCUPSdData(await aioapcaccess.request_status(host, port)) + except (OSError, asyncio.IncompleteReadError, TimeoutError): + errors = {"base": "cannot_connect"} + return self.async_show_form( + step_id="reconfigure", data_schema=_SCHEMA, errors=errors + ) + + await self.async_set_unique_id(data.serial_no) + self._abort_if_unique_id_mismatch(reason="wrong_apcupsd_daemon") + + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index 27a620491d1..d821b66ef67 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "wrong_apcupsd_daemon": "The reconfigured APC UPS Daemon is not the same as the one already configured.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py index fdb9233a0e3..a58f8c43001 100644 --- a/homeassistant/components/aprilaire/humidifier.py +++ b/homeassistant/components/aprilaire/humidifier.py @@ -62,6 +62,8 @@ async def async_setup_entry( target_humidity_key=Attribute.HUMIDIFICATION_SETPOINT, min_humidity=10, max_humidity=50, + auto_status_key=Attribute.HUMIDIFICATION_AVAILABLE, + auto_status_value=1, default_humidity=30, set_humidity_fn=coordinator.client.set_humidification_setpoint, ) @@ -77,6 +79,8 @@ async def async_setup_entry( action_map=DEHUMIDIFIER_ACTION_MAP, current_humidity_key=Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE, target_humidity_key=Attribute.DEHUMIDIFICATION_SETPOINT, + auto_status_key=None, + auto_status_value=None, min_humidity=40, max_humidity=90, default_humidity=60, @@ -100,6 +104,8 @@ class AprilaireHumidifierDescription(HumidifierEntityDescription): target_humidity_key: str min_humidity: int max_humidity: int + auto_status_key: str | None + auto_status_value: int | None default_humidity: int set_humidity_fn: Callable[[int], Awaitable] @@ -163,14 +169,31 @@ class AprilaireHumidifierEntity(BaseAprilaireEntity, HumidifierEntity): def min_humidity(self) -> float: """Return the minimum humidity.""" + if self.is_auto_humidity_mode(): + return 1 + return self.entity_description.min_humidity @property def max_humidity(self) -> float: """Return the maximum humidity.""" + if self.is_auto_humidity_mode(): + return 7 + return self.entity_description.max_humidity + def is_auto_humidity_mode(self) -> bool: + """Return whether the humidifier is in auto mode.""" + + if self.entity_description.auto_status_key is None: + return False + + return ( + self.coordinator.data.get(self.entity_description.auto_status_key) + == self.entity_description.auto_status_value + ) + async def async_set_humidity(self, humidity: int) -> None: """Set the humidity.""" diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json index 6fe3beae3bc..fa30882f669 100644 --- a/homeassistant/components/aprilaire/manifest.json +++ b/homeassistant/components/aprilaire/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyaprilaire"], - "requirements": ["pyaprilaire==0.9.0"] + "requirements": ["pyaprilaire==0.9.1"] } diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json index eb1acb40d17..e86b4a8431e 100644 --- a/homeassistant/components/apsystems/manifest.json +++ b/homeassistant/components/apsystems/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["APsystemsEZ1"], - "requirements": ["apsystems-ez1==2.6.0"] + "requirements": ["apsystems-ez1==2.7.0"] } diff --git a/homeassistant/components/aquacell/icons.json b/homeassistant/components/aquacell/icons.json index d7383f54d72..255a964d218 100644 --- a/homeassistant/components/aquacell/icons.json +++ b/homeassistant/components/aquacell/icons.json @@ -1,6 +1,9 @@ { "entity": { "sensor": { + "last_update": { + "default": "mdi:update" + }, "salt_left_side_percentage": { "default": "mdi:basket-fill" }, diff --git a/homeassistant/components/aquacell/sensor.py b/homeassistant/components/aquacell/sensor.py index 77cd3cdd60a..58d3548284e 100644 --- a/homeassistant/components/aquacell/sensor.py +++ b/homeassistant/components/aquacell/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from aioaquacell import Softener @@ -28,7 +29,7 @@ PARALLEL_UPDATES = 1 class SoftenerSensorEntityDescription(SensorEntityDescription): """Describes Softener sensor entity.""" - value_fn: Callable[[Softener], StateType] + value_fn: Callable[[Softener], StateType | datetime] SENSORS: tuple[SoftenerSensorEntityDescription, ...] = ( @@ -77,6 +78,12 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = ( "low", ], ), + SoftenerSensorEntityDescription( + key="last_update", + translation_key="last_update", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda softener: softener.lastUpdate, + ), ) @@ -111,6 +118,6 @@ class SoftenerSensor(AquacellEntity, SensorEntity): self.entity_description = description @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.softener) diff --git a/homeassistant/components/aquacell/strings.json b/homeassistant/components/aquacell/strings.json index e07adf3c199..d2052fbd08e 100644 --- a/homeassistant/components/aquacell/strings.json +++ b/homeassistant/components/aquacell/strings.json @@ -21,6 +21,9 @@ }, "entity": { "sensor": { + "last_update": { + "name": "Last update" + }, "salt_left_side_percentage": { "name": "Salt left side percentage" }, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a205db4e615..34f590574d4 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -20,9 +20,6 @@ import hass_nabucasa import voluptuous as vol from homeassistant.components import conversation, stt, tts, wake_word, websocket_api -from homeassistant.components.tts import ( - generate_media_source_id as tts_generate_media_source_id, -) from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -92,6 +89,8 @@ KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN) KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey( "pipeline_conversation_data" ) +# Number of response parts to handle before streaming the response +STREAM_RESPONSE_CHARS = 60 def validate_language(data: dict[str, Any]) -> Any: @@ -555,7 +554,7 @@ class PipelineRun: event_callback: PipelineEventCallback language: str = None # type: ignore[assignment] runner_data: Any | None = None - intent_agent: str | None = None + intent_agent: conversation.AgentInfo | None = None tts_audio_output: str | dict[str, Any] | None = None wake_word_settings: WakeWordSettings | None = None audio_settings: AudioSettings = field(default_factory=AudioSettings) @@ -591,6 +590,9 @@ class PipelineRun: _intent_agent_only = False """If request should only be handled by agent, ignoring sentence triggers and local processing.""" + _streamed_response_text = False + """If the conversation agent streamed response text to TTS result.""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -652,6 +654,11 @@ class PipelineRun: "token": self.tts_stream.token, "url": self.tts_stream.url, "mime_type": self.tts_stream.content_type, + "stream_response": ( + self.tts_stream.supports_streaming_input + and self.intent_agent + and self.intent_agent.supports_streaming + ), } self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) @@ -899,12 +906,12 @@ class PipelineRun: ) -> str: """Run speech-to-text portion of pipeline. Returns the spoken text.""" # Create a background task to prepare the conversation agent - if self.end_stage >= PipelineStage.INTENT: + if self.end_stage >= PipelineStage.INTENT and self.intent_agent: self.hass.async_create_background_task( conversation.async_prepare_agent( - self.hass, self.intent_agent, self.language + self.hass, self.intent_agent.id, self.language ), - f"prepare conversation agent {self.intent_agent}", + f"prepare conversation agent {self.intent_agent.id}", ) if isinstance(self.stt_provider, stt.Provider): @@ -1045,7 +1052,7 @@ class PipelineRun: message=f"Intent recognition engine {engine} is not found", ) - self.intent_agent = agent_info.id + self.intent_agent = agent_info async def recognize_intent( self, @@ -1078,7 +1085,7 @@ class PipelineRun: PipelineEvent( PipelineEventType.INTENT_START, { - "engine": self.intent_agent, + "engine": self.intent_agent.id, "language": input_language, "intent_input": intent_input, "conversation_id": conversation_id, @@ -1095,11 +1102,11 @@ class PipelineRun: conversation_id=conversation_id, device_id=device_id, language=input_language, - agent_id=self.intent_agent, + agent_id=self.intent_agent.id, extra_system_prompt=conversation_extra_system_prompt, ) - agent_id = self.intent_agent + agent_id = self.intent_agent.id processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT intent_response: intent.IntentResponse | None = None if not processed_locally and not self._intent_agent_only: @@ -1121,7 +1128,7 @@ class PipelineRun: # If the LLM has API access, we filter out some sentences that are # interfering with LLM operation. if ( - intent_agent_state := self.hass.states.get(self.intent_agent) + intent_agent_state := self.hass.states.get(self.intent_agent.id) ) and intent_agent_state.attributes.get( ATTR_SUPPORTED_FEATURES, 0 ) & conversation.ConversationEntityFeature.CONTROL: @@ -1143,6 +1150,13 @@ class PipelineRun: agent_id = conversation.HOME_ASSISTANT_AGENT processed_locally = True + if self.tts_stream and self.tts_stream.supports_streaming_input: + tts_input_stream: asyncio.Queue[str | None] | None = asyncio.Queue() + else: + tts_input_stream = None + chat_log_role = None + delta_character_count = 0 + @callback def chat_log_delta_listener( chat_log: conversation.ChatLog, delta: dict @@ -1156,6 +1170,61 @@ class PipelineRun: }, ) ) + if tts_input_stream is None: + return + + nonlocal chat_log_role + + if role := delta.get("role"): + chat_log_role = role + + # We are only interested in assistant deltas + if chat_log_role != "assistant": + return + + if content := delta.get("content"): + tts_input_stream.put_nowait(content) + + if self._streamed_response_text: + return + + nonlocal delta_character_count + + # Streamed responses are not cached. That's why we only start streaming text after + # we have received enough characters that indicates it will be a long response + # or if we have received text, and then a tool call. + + # Tool call after we already received text + start_streaming = delta_character_count > 0 and delta.get("tool_calls") + + # Count characters in the content and test if we exceed streaming threshold + if not start_streaming and content: + delta_character_count += len(content) + start_streaming = delta_character_count > STREAM_RESPONSE_CHARS + + if not start_streaming: + return + + self._streamed_response_text = True + + async def tts_input_stream_generator() -> AsyncGenerator[str]: + """Yield TTS input stream.""" + while (tts_input := await tts_input_stream.get()) is not None: + yield tts_input + + # Concatenate all existing queue items + parts = [] + while not tts_input_stream.empty(): + parts.append(tts_input_stream.get_nowait()) + tts_input_stream.put_nowait( + "".join( + # At this point parts is only strings, None indicates end of queue + cast(list[str], parts) + ) + ) + + assert self.tts_stream is not None + self.tts_stream.async_set_message_stream(tts_input_stream_generator()) with ( chat_session.async_get_chat_session( @@ -1199,6 +1268,8 @@ class PipelineRun: speech = conversation_result.response.speech.get("plain", {}).get( "speech", "" ) + if tts_input_stream and self._streamed_response_text: + tts_input_stream.put_nowait(None) except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") @@ -1276,26 +1347,11 @@ class PipelineRun: ) ) - try: - # Synthesize audio and get URL - tts_media_id = tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.tts_stream.engine, - language=self.tts_stream.language, - options=self.tts_stream.options, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text-to-speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text-to-speech", - ) from src_error - - self.tts_stream.async_set_message(tts_input) + if not self._streamed_response_text: + self.tts_stream.async_set_message(tts_input) tts_output = { - "media_id": tts_media_id, + "media_id": self.tts_stream.media_source_id, "token": self.tts_stream.token, "url": self.tts_stream.url, "mime_type": self.tts_stream.content_type, diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json index e3c97535a55..fbc746e939e 100644 --- a/homeassistant/components/august/strings.json +++ b/homeassistant/components/august/strings.json @@ -18,7 +18,7 @@ }, "step": { "validation": { - "title": "Two factor authentication", + "title": "Two-factor authentication", "data": { "verification_code": "Verification code" }, diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 6b28d9d8c1c..96e7ac7bcd7 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -4,8 +4,8 @@ "user": { "description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel", "data": { - "port": "RS485 or USB-RS485 Adaptor Port", - "address": "Inverter Address" + "port": "RS485 or USB-RS485 adaptor port", + "address": "Inverter address" } } }, @@ -16,7 +16,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_serial_ports": "No com ports found. Need a valid RS485 device to communicate." + "no_serial_ports": "No com ports found. The integration needs a valid RS485 device to communicate." } }, "entity": { diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index c8622880f0f..b1e80d716d8 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -5,7 +5,7 @@ "step": { "init": { "title": "Set up two-factor authentication using TOTP", - "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." + "description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." } }, "error": { @@ -13,7 +13,7 @@ } }, "notify": { - "title": "Notify One-Time Password", + "title": "Notify one-time password", "step": { "init": { "title": "Set up one-time password delivered by notify component", diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 8b4a1d4f5f5..388e360040e 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -47,7 +47,7 @@ from .const import ( CONF_VIDEO_SOURCE, DEFAULT_STREAM_PROFILE, DEFAULT_VIDEO_SOURCE, - DOMAIN as AXIS_DOMAIN, + DOMAIN, ) from .errors import AuthenticationRequired, CannotConnect from .hub import AxisHub, get_axis_api @@ -58,7 +58,7 @@ DEFAULT_PROTOCOL = "https" PROTOCOL_CHOICES = ["https", "http"] -class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): +class AxisFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Axis config flow.""" VERSION = 3 @@ -146,7 +146,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): model = self.config[CONF_MODEL] same_model = [ entry.data[CONF_NAME] - for entry in self.hass.config_entries.async_entries(AXIS_DOMAIN) + for entry in self.hass.config_entries.async_entries(DOMAIN) if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model ] diff --git a/homeassistant/components/axis/entity.py b/homeassistant/components/axis/entity.py index b952000cca8..596d07de40f 100644 --- a/homeassistant/components/axis/entity.py +++ b/homeassistant/components/axis/entity.py @@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription -from .const import DOMAIN as AXIS_DOMAIN +from .const import DOMAIN if TYPE_CHECKING: from .hub import AxisHub @@ -61,7 +61,7 @@ class AxisEntity(Entity): self.hub = hub self._attr_device_info = DeviceInfo( - identifiers={(AXIS_DOMAIN, hub.unique_id)}, + identifiers={(DOMAIN, hub.unique_id)}, serial_number=hub.unique_id, ) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 124ce8b872c..daf9337a8a8 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -23,6 +23,7 @@ from .const import DATA_MANAGER, DOMAIN from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator from .http import async_register_http_views from .manager import ( + AddonErrorData, BackupManager, BackupManagerError, BackupPlatformEvent, @@ -48,6 +49,7 @@ from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers __all__ = [ + "AddonErrorData", "AddonInfo", "AgentBackup", "BackupAgent", @@ -79,7 +81,7 @@ __all__ = [ "suggested_filename_from_name_date", ] -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.EVENT, Platform.SENSOR] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/coordinator.py b/homeassistant/components/backup/coordinator.py index 377f23567e0..3f6146f68d7 100644 --- a/homeassistant/components/backup/coordinator.py +++ b/homeassistant/components/backup/coordinator.py @@ -30,8 +30,10 @@ class BackupCoordinatorData: """Class to hold backup data.""" backup_manager_state: BackupManagerState + last_attempted_automatic_backup: datetime | None last_successful_automatic_backup: datetime | None next_scheduled_automatic_backup: datetime | None + last_event: ManagerStateEvent | BackupPlatformEvent | None class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]): @@ -59,19 +61,23 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]): ] self.backup_manager = backup_manager + self._last_event: ManagerStateEvent | BackupPlatformEvent | None = None @callback def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None: """Handle new event.""" LOGGER.debug("Received backup event: %s", event) + self._last_event = event self.config_entry.async_create_task(self.hass, self.async_refresh()) async def _async_update_data(self) -> BackupCoordinatorData: """Update backup manager data.""" return BackupCoordinatorData( self.backup_manager.state, + self.backup_manager.config.data.last_attempted_automatic_backup, self.backup_manager.config.data.last_completed_automatic_backup, self.backup_manager.config.data.schedule.next_automatic_backup, + self._last_event, ) @callback diff --git a/homeassistant/components/backup/entity.py b/homeassistant/components/backup/entity.py index ff7c7889dc5..f07a6a4e4dc 100644 --- a/homeassistant/components/backup/entity.py +++ b/homeassistant/components/backup/entity.py @@ -11,7 +11,7 @@ from .const import DOMAIN from .coordinator import BackupDataUpdateCoordinator -class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]): +class BackupManagerBaseEntity(CoordinatorEntity[BackupDataUpdateCoordinator]): """Base entity for backup manager.""" _attr_has_entity_name = True @@ -19,12 +19,9 @@ class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]): def __init__( self, coordinator: BackupDataUpdateCoordinator, - entity_description: EntityDescription, ) -> None: """Initialize base entity.""" super().__init__(coordinator) - self.entity_description = entity_description - self._attr_unique_id = entity_description.key self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, "backup_manager")}, manufacturer="Home Assistant", @@ -34,3 +31,17 @@ class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]): entry_type=DeviceEntryType.SERVICE, configuration_url="homeassistant://config/backup", ) + + +class BackupManagerEntity(BackupManagerBaseEntity): + """Entity for backup manager.""" + + def __init__( + self, + coordinator: BackupDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = entity_description.key diff --git a/homeassistant/components/backup/event.py b/homeassistant/components/backup/event.py new file mode 100644 index 00000000000..17c89339148 --- /dev/null +++ b/homeassistant/components/backup/event.py @@ -0,0 +1,59 @@ +"""Event platform for Home Assistant Backup integration.""" + +from __future__ import annotations + +from typing import Final + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator +from .entity import BackupManagerBaseEntity +from .manager import CreateBackupEvent, CreateBackupState + +ATTR_BACKUP_STAGE: Final[str] = "backup_stage" +ATTR_FAILED_REASON: Final[str] = "failed_reason" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BackupConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Event set up for backup config entry.""" + coordinator = config_entry.runtime_data + async_add_entities([AutomaticBackupEvent(coordinator)]) + + +class AutomaticBackupEvent(BackupManagerBaseEntity, EventEntity): + """Representation of an automatic backup event.""" + + _attr_event_types = [s.value for s in CreateBackupState] + _unrecorded_attributes = frozenset({ATTR_FAILED_REASON, ATTR_BACKUP_STAGE}) + coordinator: BackupDataUpdateCoordinator + + def __init__(self, coordinator: BackupDataUpdateCoordinator) -> None: + """Initialize the automatic backup event.""" + super().__init__(coordinator) + self._attr_unique_id = "automatic_backup_event" + self._attr_translation_key = "automatic_backup_event" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if ( + not (data := self.coordinator.data) + or (event := data.last_event) is None + or not isinstance(event, CreateBackupEvent) + ): + return + + self._trigger_event( + event.state, + { + ATTR_BACKUP_STAGE: event.stage, + ATTR_FAILED_REASON: event.reason, + }, + ) + self.async_write_ha_state() diff --git a/homeassistant/components/backup/icons.json b/homeassistant/components/backup/icons.json index 8a412f66edc..6ba50780cda 100644 --- a/homeassistant/components/backup/icons.json +++ b/homeassistant/components/backup/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "event": { + "automatic_backup_event": { + "default": "mdi:database" + } + } + }, "services": { "create": { "service": "mdi:cloud-upload" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 43a7be6db8d..8dbce1b455c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -62,6 +62,7 @@ from .const import ( LOGGER, ) from .models import ( + AddonInfo, AgentBackup, BackupError, BackupManagerError, @@ -102,15 +103,27 @@ class ManagerBackup(BaseBackup): """Backup class.""" agents: dict[str, AgentBackupStatus] + failed_addons: list[AddonInfo] failed_agent_ids: list[str] + failed_folders: list[Folder] with_automatic_settings: bool | None +@dataclass(frozen=True, kw_only=True, slots=True) +class AddonErrorData: + """Addon error class.""" + + addon: AddonInfo + errors: list[tuple[str, str]] + + @dataclass(frozen=True, kw_only=True, slots=True) class WrittenBackup: """Written backup class.""" + addon_errors: dict[str, AddonErrorData] backup: AgentBackup + folder_errors: dict[Folder, list[tuple[str, str]]] open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]] release_stream: Callable[[], Coroutine[Any, Any, None]] @@ -636,9 +649,13 @@ class BackupManager: for agent_backup in result: if (backup_id := agent_backup.backup_id) not in backups: if known_backup := self.known_backups.get(backup_id): + failed_addons = known_backup.failed_addons failed_agent_ids = known_backup.failed_agent_ids + failed_folders = known_backup.failed_folders else: + failed_addons = [] failed_agent_ids = [] + failed_folders = [] with_automatic_settings = self.is_our_automatic_backup( agent_backup, await instance_id.async_get(self.hass) ) @@ -649,7 +666,9 @@ class BackupManager: date=agent_backup.date, database_included=agent_backup.database_included, extra_metadata=agent_backup.extra_metadata, + failed_addons=failed_addons, failed_agent_ids=failed_agent_ids, + failed_folders=failed_folders, folders=agent_backup.folders, homeassistant_included=agent_backup.homeassistant_included, homeassistant_version=agent_backup.homeassistant_version, @@ -704,9 +723,13 @@ class BackupManager: continue if backup is None: if known_backup := self.known_backups.get(backup_id): + failed_addons = known_backup.failed_addons failed_agent_ids = known_backup.failed_agent_ids + failed_folders = known_backup.failed_folders else: + failed_addons = [] failed_agent_ids = [] + failed_folders = [] with_automatic_settings = self.is_our_automatic_backup( result, await instance_id.async_get(self.hass) ) @@ -717,7 +740,9 @@ class BackupManager: date=result.date, database_included=result.database_included, extra_metadata=result.extra_metadata, + failed_addons=failed_addons, failed_agent_ids=failed_agent_ids, + failed_folders=failed_folders, folders=result.folders, homeassistant_included=result.homeassistant_included, homeassistant_version=result.homeassistant_version, @@ -960,7 +985,7 @@ class BackupManager: password=None, ) await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors, []) + self.known_backups.add(written_backup.backup, agent_errors, {}, {}, []) return written_backup.backup.backup_id async def async_create_backup( @@ -1198,7 +1223,11 @@ class BackupManager: finally: await written_backup.release_stream() self.known_backups.add( - written_backup.backup, agent_errors, unavailable_agents + written_backup.backup, + agent_errors, + written_backup.addon_errors, + written_backup.folder_errors, + unavailable_agents, ) if not agent_errors: if with_automatic_settings: @@ -1208,7 +1237,9 @@ class BackupManager: backup_success = True if with_automatic_settings: - self._update_issue_after_agent_upload(agent_errors, unavailable_agents) + self._update_issue_after_agent_upload( + written_backup, agent_errors, unavailable_agents + ) # delete old backups more numerous than copies # try this regardless of agent errors above await delete_backups_exceeding_configured_count(self) @@ -1354,8 +1385,10 @@ class BackupManager: for subscription in self._backup_event_subscriptions: subscription(event) - def _update_issue_backup_failed(self) -> None: - """Update issue registry when a backup fails.""" + def _create_automatic_backup_failed_issue( + self, translation_key: str, translation_placeholders: dict[str, str] | None + ) -> None: + """Create an issue in the issue registry for automatic backup failures.""" ir.async_create_issue( self.hass, DOMAIN, @@ -1364,37 +1397,73 @@ class BackupManager: is_persistent=True, learn_more_url="homeassistant://config/backup", severity=ir.IssueSeverity.WARNING, - translation_key="automatic_backup_failed_create", + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + def _update_issue_backup_failed(self) -> None: + """Update issue registry when a backup fails.""" + self._create_automatic_backup_failed_issue( + "automatic_backup_failed_create", None ) def _update_issue_after_agent_upload( - self, agent_errors: dict[str, Exception], unavailable_agents: list[str] + self, + written_backup: WrittenBackup, + agent_errors: dict[str, Exception], + unavailable_agents: list[str], ) -> None: """Update issue registry after a backup is uploaded to agents.""" - if not agent_errors and not unavailable_agents: + + addon_errors = written_backup.addon_errors + failed_agents = unavailable_agents + [ + self.backup_agents[agent_id].name for agent_id in agent_errors + ] + folder_errors = written_backup.folder_errors + + if not failed_agents and not addon_errors and not folder_errors: + # No issues to report, clear previous error ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") return - ir.async_create_issue( - self.hass, - DOMAIN, - "automatic_backup_failed", - is_fixable=False, - is_persistent=True, - learn_more_url="homeassistant://config/backup", - severity=ir.IssueSeverity.WARNING, - translation_key="automatic_backup_failed_upload_agents", - translation_placeholders={ - "failed_agents": ", ".join( - chain( - ( - self.backup_agents[agent_id].name - for agent_id in agent_errors - ), - unavailable_agents, + if failed_agents and not (addon_errors or folder_errors): + # No issues with add-ons or folders, but issues with agents + self._create_automatic_backup_failed_issue( + "automatic_backup_failed_upload_agents", + {"failed_agents": ", ".join(failed_agents)}, + ) + elif addon_errors and not (failed_agents or folder_errors): + # No issues with agents or folders, but issues with add-ons + self._create_automatic_backup_failed_issue( + "automatic_backup_failed_addons", + { + "failed_addons": ", ".join( + val.addon.name or val.addon.slug + for val in addon_errors.values() ) - ) - }, - ) + }, + ) + elif folder_errors and not (failed_agents or addon_errors): + # No issues with agents or add-ons, but issues with folders + self._create_automatic_backup_failed_issue( + "automatic_backup_failed_folders", + {"failed_folders": ", ".join(folder for folder in folder_errors)}, + ) + else: + # Issues with agents, add-ons, and/or folders + self._create_automatic_backup_failed_issue( + "automatic_backup_failed_agents_addons_folders", + { + "failed_agents": ", ".join(failed_agents) or "-", + "failed_addons": ( + ", ".join( + val.addon.name or val.addon.slug + for val in addon_errors.values() + ) + or "-" + ), + "failed_folders": ", ".join(f for f in folder_errors) or "-", + }, + ) async def async_can_decrypt_on_download( self, @@ -1460,7 +1529,12 @@ class KnownBackups: self._backups = { backup["backup_id"]: KnownBackup( backup_id=backup["backup_id"], + failed_addons=[ + AddonInfo(name=a["name"], slug=a["slug"], version=a["version"]) + for a in backup["failed_addons"] + ], failed_agent_ids=backup["failed_agent_ids"], + failed_folders=[Folder(f) for f in backup["failed_folders"]], ) for backup in stored_backups } @@ -1473,12 +1547,16 @@ class KnownBackups: self, backup: AgentBackup, agent_errors: dict[str, Exception], + failed_addons: dict[str, AddonErrorData], + failed_folders: dict[Folder, list[tuple[str, str]]], unavailable_agents: list[str], ) -> None: """Add a backup.""" self._backups[backup.backup_id] = KnownBackup( backup_id=backup.backup_id, + failed_addons=[val.addon for val in failed_addons.values()], failed_agent_ids=list(chain(agent_errors, unavailable_agents)), + failed_folders=list(failed_folders), ) self._manager.store.save() @@ -1499,21 +1577,38 @@ class KnownBackup: """Persistent backup data.""" backup_id: str + failed_addons: list[AddonInfo] failed_agent_ids: list[str] + failed_folders: list[Folder] def to_dict(self) -> StoredKnownBackup: """Convert known backup to a dict.""" return { "backup_id": self.backup_id, + "failed_addons": [ + {"name": a.name, "slug": a.slug, "version": a.version} + for a in self.failed_addons + ], "failed_agent_ids": self.failed_agent_ids, + "failed_folders": [f.value for f in self.failed_folders], } +class StoredAddonInfo(TypedDict): + """Stored add-on info.""" + + name: str | None + slug: str + version: str | None + + class StoredKnownBackup(TypedDict): """Stored persistent backup data.""" backup_id: str + failed_addons: list[StoredAddonInfo] failed_agent_ids: list[str] + failed_folders: list[str] class CoreBackupReaderWriter(BackupReaderWriter): @@ -1677,7 +1772,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): raise BackupReaderWriterError(str(err)) from err return WrittenBackup( - backup=backup, open_stream=open_backup, release_stream=remove_backup + addon_errors={}, + backup=backup, + folder_errors={}, + open_stream=open_backup, + release_stream=remove_backup, ) finally: # Inform integrations the backup is done @@ -1816,7 +1915,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): await async_add_executor_job(temp_file.unlink, True) return WrittenBackup( - backup=backup, open_stream=open_backup, release_stream=remove_backup + addon_errors={}, + backup=backup, + folder_errors={}, + open_stream=open_backup, + release_stream=remove_backup, ) async def async_restore_backup( diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index 95c5ef9809d..d927cd0bac5 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -13,9 +13,9 @@ from homeassistant.exceptions import HomeAssistantError class AddonInfo: """Addon information.""" - name: str + name: str | None slug: str - version: str + version: str | None class Folder(StrEnum): diff --git a/homeassistant/components/backup/sensor.py b/homeassistant/components/backup/sensor.py index 59e98ae7c2d..08e7ec49e3d 100644 --- a/homeassistant/components/backup/sensor.py +++ b/homeassistant/components/backup/sensor.py @@ -46,6 +46,12 @@ BACKUP_MANAGER_DESCRIPTIONS = ( device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.last_successful_automatic_backup, ), + BackupSensorEntityDescription( + key="last_attempted_automatic_backup", + translation_key="last_attempted_automatic_backup", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.last_attempted_automatic_backup, + ), ) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 6472f8ae151..17ef1d3a8fb 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 = 6 +STORAGE_VERSION_MINOR = 7 class StoredBackupData(TypedDict): @@ -76,8 +76,16 @@ class _BackupStore(Store[StoredBackupData]): # Version 1.6 adds agent retention settings for agent in data["config"]["agents"]: data["config"]["agents"][agent]["retention"] = None + if old_minor_version < 7: + # Version 1.7 adds failing addons and folders + for backup in data["backups"]: + backup["failed_addons"] = [] + backup["failed_folders"] = [] - # Note: We allow reading data with major version 2. + # Note: We allow reading data with major version 2 in which the unused key + # data["config"]["schedule"]["state"] will be removed. The bump to 2 is + # planned to happen after a 6 month quiet period with no minor version + # changes. # Reject if major version is higher than 2. if old_major_version > 2: raise NotImplementedError diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 357bcdbb72f..1b04542dbae 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -11,6 +11,18 @@ "automatic_backup_failed_upload_agents": { "title": "Automatic backup could not be uploaded to the configured locations", "description": "The automatic backup could not be uploaded to the configured locations {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." + }, + "automatic_backup_failed_addons": { + "title": "Not all add-ons could be included in automatic backup", + "description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." + }, + "automatic_backup_failed_agents_addons_folders": { + "title": "Automatic backup was created with errors", + "description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the core and supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." + }, + "automatic_backup_failed_folders": { + "title": "Not all folders could be included in automatic backup", + "description": "Folders {failed_folders} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." } }, "services": { @@ -24,6 +36,22 @@ } }, "entity": { + "event": { + "automatic_backup_event": { + "name": "Automatic backup", + "state_attributes": { + "event_type": { + "state": { + "completed": "Completed successfully", + "failed": "Failed", + "in_progress": "In progress" + } + }, + "backup_stage": { "name": "Backup stage" }, + "failed_reason": { "name": "Failure reason" } + } + } + }, "sensor": { "backup_manager_state": { "name": "Backup Manager state", @@ -37,6 +65,9 @@ "next_scheduled_automatic_backup": { "name": "Next scheduled automatic backup" }, + "last_attempted_automatic_backup": { + "name": "Last attempted automatic backup" + }, "last_successful_automatic_backup": { "name": "Last successful automatic backup" } diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 8112faf4459..1a32c938a54 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -332,6 +332,9 @@ def decrypt_backup( except (DecryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error decrypting backup: %s", err) error = err + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected error when decrypting backup: %s", err) + error = err else: # Pad the output stream to the requested minimum size padding = max(minimum_size - output_stream.tell(), 0) @@ -417,6 +420,9 @@ def encrypt_backup( except (EncryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error encrypting backup: %s", err) error = err + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected error when decrypting backup: %s", err) + error = err else: # Pad the output stream to the requested minimum size padding = max(minimum_size - output_stream.tell(), 0) diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index dbf4a326990..2d1f6c5ae9e 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -21,7 +21,6 @@ from .entity import BleBoxEntity SCAN_INTERVAL = timedelta(seconds=5) BLEBOX_TO_HVACMODE = { - None: None, 0: HVACMode.OFF, 1: HVACMode.HEAT, 2: HVACMode.COOL, @@ -59,12 +58,14 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn _attr_temperature_unit = UnitOfTemperature.CELSIUS @property - def hvac_modes(self): + def hvac_modes(self) -> list[HVACMode]: """Return list of supported HVAC modes.""" + if self._feature.mode is None: + return [HVACMode.OFF] return [HVACMode.OFF, BLEBOX_TO_HVACMODE[self._feature.mode]] @property - def hvac_mode(self): + def hvac_mode(self) -> HVACMode | None: """Return the desired HVAC mode.""" if self._feature.is_on is None: return None @@ -75,7 +76,7 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn return HVACMode.HEAT if self._feature.is_on else HVACMode.OFF @property - def hvac_action(self): + def hvac_action(self) -> HVACAction | None: """Return the actual current HVAC action.""" if self._feature.hvac_action is not None: if not self._feature.is_on: @@ -88,22 +89,22 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn return HVACAction.HEATING if self._feature.is_heating else HVACAction.IDLE @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature supported.""" return self._feature.max_temp @property - def min_temp(self): + def min_temp(self) -> float: """Return the maximum temperature supported.""" return self._feature.min_temp @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._feature.current @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the desired thermostat temperature.""" return self._feature.desired diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 86ec8993779..75900ca7d97 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -84,7 +84,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): return color_util.color_temperature_mired_to_kelvin(self._feature.color_temp) @property - def color_mode(self): + def color_mode(self) -> ColorMode: """Return the color mode. Set values to _attr_ibutes if needed. @@ -92,7 +92,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): return COLOR_MODE_MAP.get(self._feature.color_mode, ColorMode.ONOFF) @property - def supported_color_modes(self): + def supported_color_modes(self) -> set[ColorMode]: """Return supported color modes.""" return {self.color_mode} @@ -107,7 +107,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): return self._feature.effect @property - def rgb_color(self): + def rgb_color(self) -> tuple[int, int, int] | None: """Return value for rgb.""" if (rgb_hex := self._feature.rgb_hex) is None: return None @@ -118,14 +118,14 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): ) @property - def rgbw_color(self): + def rgbw_color(self) -> tuple[int, int, int, int] | None: """Return the hue and saturation.""" if (rgbw_hex := self._feature.rgbw_hex) is None: return None return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbw_hex)[0:4]) @property - def rgbww_color(self): + def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return value for rgbww.""" if (rgbww_hex := self._feature.rgbww_hex) is None: return None diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 74f8ae1cb28..8f8df125aab 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Sign-in with Blink account", + "title": "Sign in with Blink account", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -30,7 +30,7 @@ "step": { "simple_options": { "data": { - "scan_interval": "Scan Interval (seconds)" + "scan_interval": "Scan interval (seconds)" }, "title": "Blink options", "description": "Configure Blink integration" @@ -93,7 +93,7 @@ }, "config_entry_id": { "name": "Integration ID", - "description": "The Blink Integration ID." + "description": "The Blink integration ID." } } } diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 6d0ccd7b6db..775ca16a12a 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -24,7 +24,7 @@ from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE type BlueCurrentConfigEntry = ConfigEntry[Connector] -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR] CHARGE_POINTS = "CHARGE_POINTS" DATA = "data" DELAY = 5 diff --git a/homeassistant/components/blue_current/button.py b/homeassistant/components/blue_current/button.py new file mode 100644 index 00000000000..9d2cde547ca --- /dev/null +++ b/homeassistant/components/blue_current/button.py @@ -0,0 +1,89 @@ +"""Support for Blue Current buttons.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from bluecurrent_api.client import Client + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BlueCurrentConfigEntry, Connector +from .entity import ChargepointEntity + + +@dataclass(kw_only=True, frozen=True) +class ChargePointButtonEntityDescription(ButtonEntityDescription): + """Describes a Blue Current button entity.""" + + function: Callable[[Client, str], Coroutine[Any, Any, None]] + + +CHARGE_POINT_BUTTONS = ( + ChargePointButtonEntityDescription( + key="reset", + translation_key="reset", + function=lambda client, evse_id: client.reset(evse_id), + device_class=ButtonDeviceClass.RESTART, + ), + ChargePointButtonEntityDescription( + key="reboot", + translation_key="reboot", + function=lambda client, evse_id: client.reboot(evse_id), + device_class=ButtonDeviceClass.RESTART, + ), + ChargePointButtonEntityDescription( + key="stop_charge_session", + translation_key="stop_charge_session", + function=lambda client, evse_id: client.stop_session(evse_id), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BlueCurrentConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Blue Current buttons.""" + connector: Connector = entry.runtime_data + async_add_entities( + ChargePointButton( + connector, + button, + evse_id, + ) + for evse_id in connector.charge_points + for button in CHARGE_POINT_BUTTONS + ) + + +class ChargePointButton(ChargepointEntity, ButtonEntity): + """Define a charge point button.""" + + has_value = True + entity_description: ChargePointButtonEntityDescription + + def __init__( + self, + connector: Connector, + description: ChargePointButtonEntityDescription, + evse_id: str, + ) -> None: + """Initialize the button.""" + super().__init__(connector, evse_id) + + self.entity_description = description + self._attr_unique_id = f"{description.key}_{evse_id}" + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.function(self.connector.client, self.evse_id) diff --git a/homeassistant/components/blue_current/entity.py b/homeassistant/components/blue_current/entity.py index cae7d420c99..426b7c06845 100644 --- a/homeassistant/components/blue_current/entity.py +++ b/homeassistant/components/blue_current/entity.py @@ -1,7 +1,5 @@ """Entity representing a Blue Current charge point.""" -from abc import abstractmethod - from homeassistant.const import ATTR_NAME from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -17,12 +15,12 @@ class BlueCurrentEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False + has_value = False def __init__(self, connector: Connector, signal: str) -> None: """Initialize the entity.""" self.connector = connector self.signal = signal - self.has_value = False async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -43,7 +41,6 @@ class BlueCurrentEntity(Entity): return self.connector.connected and self.has_value @callback - @abstractmethod def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index b5a5f2be81e..ce936902e91 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -19,6 +19,17 @@ "current_left": { "default": "mdi:gauge" } + }, + "button": { + "reset": { + "default": "mdi:restart" + }, + "reboot": { + "default": "mdi:restart-alert" + }, + "stop_charge_session": { + "default": "mdi:stop" + } } } } diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json index 4f277e83656..e813b08131c 100644 --- a/homeassistant/components/blue_current/manifest.json +++ b/homeassistant/components/blue_current/manifest.json @@ -1,7 +1,7 @@ { "domain": "blue_current", "name": "Blue Current", - "codeowners": ["@Floris272", "@gleeuwen"], + "codeowners": ["@gleeuwen", "@NickKoepr", "@jtodorova23"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index a8a9aff7f08..28eb20fa912 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -113,6 +113,17 @@ "grid_max_current": { "name": "Max grid current" } + }, + "button": { + "stop_charge_session": { + "name": "Stop charge session" + }, + "reboot": { + "name": "Reboot" + }, + "reset": { + "name": "Reset" + } } } } diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index 37e83ce2c47..d5dfbb4b582 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -21,6 +21,7 @@ from .coordinator import ( CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ + Platform.BUTTON, Platform.MEDIA_PLAYER, ] diff --git a/homeassistant/components/bluesound/button.py b/homeassistant/components/bluesound/button.py new file mode 100644 index 00000000000..4c9d363fa5f --- /dev/null +++ b/homeassistant/components/bluesound/button.py @@ -0,0 +1,128 @@ +"""Button entities for Bluesound.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pyblu import Player + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BluesoundCoordinator +from .media_player import DEFAULT_PORT +from .utils import format_unique_id + +if TYPE_CHECKING: + from . import BluesoundConfigEntry + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BluesoundConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Bluesound entry.""" + + async_add_entities( + BluesoundButton( + config_entry.runtime_data.coordinator, + config_entry.runtime_data.player, + config_entry.data[CONF_PORT], + description, + ) + for description in BUTTON_DESCRIPTIONS + ) + + +@dataclass(kw_only=True, frozen=True) +class BluesoundButtonEntityDescription(ButtonEntityDescription): + """Description for Bluesound button entities.""" + + press_fn: Callable[[Player], Awaitable[None]] + + +async def clear_sleep_timer(player: Player) -> None: + """Clear the sleep timer.""" + sleep = -1 + while sleep != 0: + sleep = await player.sleep_timer() + + +async def set_sleep_timer(player: Player) -> None: + """Set the sleep timer.""" + await player.sleep_timer() + + +BUTTON_DESCRIPTIONS = [ + BluesoundButtonEntityDescription( + key="set_sleep_timer", + translation_key="set_sleep_timer", + entity_registry_enabled_default=False, + press_fn=set_sleep_timer, + ), + BluesoundButtonEntityDescription( + key="clear_sleep_timer", + translation_key="clear_sleep_timer", + entity_registry_enabled_default=False, + press_fn=clear_sleep_timer, + ), +] + + +class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity): + """Base class for Bluesound buttons.""" + + _attr_has_entity_name = True + entity_description: BluesoundButtonEntityDescription + + def __init__( + self, + coordinator: BluesoundCoordinator, + player: Player, + port: int, + description: BluesoundButtonEntityDescription, + ) -> None: + """Initialize the Bluesound button.""" + super().__init__(coordinator) + sync_status = coordinator.data.sync_status + + self.entity_description = description + self._player = player + self._attr_unique_id = ( + f"{description.key}-{format_unique_id(sync_status.mac, port)}" + ) + + if port == DEFAULT_PORT: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_mac(sync_status.mac))}, + connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))}, + name=sync_status.name, + manufacturer=sync_status.brand, + model=sync_status.model_name, + model_id=sync_status.model, + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_unique_id(sync_status.mac, port))}, + name=sync_status.name, + manufacturer=sync_status.brand, + model=sync_status.model_name, + model_id=sync_status.model, + via_device=(DOMAIN, format_mac(sync_status.mac)), + ) + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_fn(self._player) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 337dc3d3a33..2662562f575 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -22,7 +22,11 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + issue_registry as ir, +) from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -34,7 +38,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, slugify from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN from .coordinator import BluesoundCoordinator @@ -488,10 +492,36 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity async def async_increase_timer(self) -> int: """Increase sleep time on player.""" + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_service_{SERVICE_SET_TIMER}", + is_fixable=False, + breaks_in_ha_version="2025.12.0", + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_service_set_sleep_timer", + translation_placeholders={ + "name": slugify(self.sync_status.name), + }, + ) return await self._player.sleep_timer() async def async_clear_timer(self) -> None: """Clear sleep timer on player.""" + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_service_{SERVICE_CLEAR_TIMER}", + is_fixable=False, + breaks_in_ha_version="2025.12.0", + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_service_clear_sleep_timer", + translation_placeholders={ + "name": slugify(self.sync_status.name), + }, + ) sleep = 1 while sleep > 0: sleep = await self._player.sleep_timer() diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json index 1170e0b92e0..236113a835b 100644 --- a/homeassistant/components/bluesound/strings.json +++ b/homeassistant/components/bluesound/strings.json @@ -26,6 +26,16 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, + "issues": { + "deprecated_service_set_sleep_timer": { + "title": "Detected use of deprecated action bluesound.set_sleep_timer", + "description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts." + }, + "deprecated_service_clear_sleep_timer": { + "title": "Detected use of deprecated action bluesound.clear_sleep_timer", + "description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts." + } + }, "services": { "join": { "name": "Join", @@ -71,5 +81,15 @@ } } } + }, + "entity": { + "button": { + "set_sleep_timer": { + "name": "Set sleep timer" + }, + "clear_sleep_timer": { + "name": "Clear sleep timer" + } + } } } diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f9377443296..f212f4bdc17 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.5.1", + "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.1", "dbus-fast==2.43.0", - "habluetooth==3.48.2" + "habluetooth==3.49.0" ] } diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index f8980201f3f..726c3ff3f6e 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .entity import BMWBaseEntity if TYPE_CHECKING: @@ -111,7 +111,7 @@ class BMWButton(BMWBaseEntity, ButtonEntity): await self.entity_description.remote_function(self.vehicle) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index b54d9245bbd..73e19ca7af5 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -22,13 +22,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.ssl import get_default_context -from .const import ( - CONF_GCID, - CONF_READ_ONLY, - CONF_REFRESH_TOKEN, - DOMAIN as BMW_DOMAIN, - SCAN_INTERVALS, -) +from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS _LOGGER = logging.getLogger(__name__) @@ -63,7 +57,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): hass, _LOGGER, config_entry=config_entry, - name=f"{BMW_DOMAIN}-{config_entry.data[CONF_USERNAME]}", + name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}", update_interval=timedelta( seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]] ), @@ -81,26 +75,26 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): except MyBMWCaptchaMissingError as err: # If a captcha is required (user/password login flow), always trigger the reauth flow raise ConfigEntryAuthFailed( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="missing_captcha", ) from err except MyBMWAuthError as err: # Allow one retry interval before raising AuthFailed to avoid flaky API issues if self.last_update_success: raise UpdateFailed( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="update_failed", translation_placeholders={"exception": str(err)}, ) from err # Clear refresh token and trigger reauth if previous update failed as well self._update_config_entry_refresh_token(None) raise ConfigEntryAuthFailed( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_auth", ) from err except (MyBMWAPIError, RequestError) as err: raise UpdateFailed( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="update_failed", translation_placeholders={"exception": str(err)}, ) from err diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 9d8965d6ebf..149647a3397 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -71,7 +71,7 @@ class BMWLock(BMWBaseEntity, LockEntity): self._attr_is_locked = None self.async_write_ha_state() raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex @@ -95,7 +95,7 @@ class BMWLock(BMWBaseEntity, LockEntity): self._attr_is_locked = None self.async_write_ha_state() raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py index dfa0939e81f..2a94cf42853 100644 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry PARALLEL_UPDATES = 1 @@ -92,7 +92,7 @@ class BMWNotificationService(BaseNotificationService): except (vol.Invalid, TypeError, ValueError) as ex: raise ServiceValidationError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_poi", translation_placeholders={ "poi_exception": str(ex), @@ -107,7 +107,7 @@ class BMWNotificationService(BaseNotificationService): await vehicle.remote_services.trigger_send_poi(poi) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index 8361306ba9d..a30775caf60 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -110,7 +110,7 @@ class BMWNumber(BMWBaseEntity, NumberEntity): await self.entity_description.remote_service(self.vehicle, value) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index f144d3a71df..81e01b2bfad 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -124,7 +124,7 @@ class BMWSelect(BMWBaseEntity, SelectEntity): await self.entity_description.remote_service(self.vehicle, option) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index d094116725f..3b8b6fc5ff0 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -69,7 +69,7 @@ "name": "Door lock state" }, "condition_based_services": { - "name": "Condition based services" + "name": "Condition-based services" }, "check_control_messages": { "name": "Check control messages" @@ -81,7 +81,7 @@ "name": "Connection status" }, "is_pre_entry_climatization_enabled": { - "name": "Pre entry climatization" + "name": "Pre-entry climatization" } }, "button": { diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index f46969f3e9b..cedcf2a7364 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -112,7 +112,7 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity): await self.entity_description.remote_service_on(self.vehicle) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex @@ -124,7 +124,7 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity): await self.entity_description.remote_service_off(self.vehicle) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index eb28bebdb06..00b8c8a0e13 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp import ClientError, ClientResponseError, ClientTimeout -from bond_async import Bond, BPUPSubscriptions, start_bpup +from bond_async import Bond, BPUPSubscriptions, RequestorUUID, start_bpup from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -49,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BondConfigEntry) -> bool token=token, timeout=ClientTimeout(total=_API_TIMEOUT), session=async_get_clientsession(hass), + requestor_uuid=RequestorUUID.HOME_ASSISTANT, ) hub = BondHub(bond, host) try: diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index ffa0098840c..9fcfbd342d8 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -8,7 +8,7 @@ import logging from typing import Any from aiohttp import ClientConnectionError, ClientResponseError -from bond_async import Bond +from bond_async import Bond, RequestorUUID import voluptuous as vol from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult @@ -34,7 +34,12 @@ TOKEN_SCHEMA = vol.Schema({}) async def async_get_token(hass: HomeAssistant, host: str) -> str | None: """Try to fetch the token from the bond device.""" - bond = Bond(host, "", session=async_get_clientsession(hass)) + bond = Bond( + host, + "", + session=async_get_clientsession(hass), + requestor_uuid=RequestorUUID.HOME_ASSISTANT, + ) response: dict[str, str] = {} with contextlib.suppress(ClientConnectionError): response = await bond.token() @@ -45,7 +50,10 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[st """Validate the user input allows us to connect.""" bond = Bond( - data[CONF_HOST], data[CONF_ACCESS_TOKEN], session=async_get_clientsession(hass) + data[CONF_HOST], + data[CONF_ACCESS_TOKEN], + session=async_get_clientsession(hass), + requestor_uuid=RequestorUUID.HOME_ASSISTANT, ) try: hub = BondHub(bond, data[CONF_HOST]) diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index 602c801701d..7f37476f1bb 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -6,17 +6,31 @@ from ssl import SSLError 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.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.typing import ConfigType from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN +from .services import setup_services +from .types import BoschAlarmConfigEntry -PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -type BoschAlarmConfigEntry = ConfigEntry[Panel] +PLATFORMS: list[Platform] = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up bosch alarm services.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool: @@ -48,8 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) - device_registry = dr.async_get(hass) + mac = entry.data.get(CONF_MAC) + device_registry.async_get_or_create( config_entry_id=entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(), identifiers={(DOMAIN, entry.unique_id or entry.entry_id)}, name=f"Bosch {panel.model}", manufacturer="Bosch Security Systems", diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py index 2854298f815..b502ee32fca 100644 --- a/homeassistant/components/bosch_alarm/alarm_control_panel.py +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -12,8 +12,8 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import BoschAlarmConfigEntry from .entity import BoschAlarmAreaEntity +from .types import BoschAlarmConfigEntry async def async_setup_entry( @@ -34,6 +34,9 @@ async def async_setup_entry( ) +PARALLEL_UPDATES = 0 + + class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity): """An alarm control panel entity for a bosch alarm panel.""" @@ -47,7 +50,7 @@ class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity): def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: """Initialise a Bosch Alarm control panel entity.""" - super().__init__(panel, area_id, unique_id, False, False, True) + super().__init__(panel, area_id, unique_id, True, False, True) self._attr_unique_id = self._area_unique_id @property diff --git a/homeassistant/components/bosch_alarm/binary_sensor.py b/homeassistant/components/bosch_alarm/binary_sensor.py new file mode 100644 index 00000000000..ced97f04686 --- /dev/null +++ b/homeassistant/components/bosch_alarm/binary_sensor.py @@ -0,0 +1,220 @@ +"""Support for Bosch Alarm Panel binary sensors.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS + +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 . import BoschAlarmConfigEntry +from .entity import BoschAlarmAreaEntity, BoschAlarmEntity, BoschAlarmPointEntity + + +@dataclass(kw_only=True, frozen=True) +class BoschAlarmFaultEntityDescription(BinarySensorEntityDescription): + """Describes Bosch Alarm sensor entity.""" + + fault: int + + +FAULT_TYPES = [ + BoschAlarmFaultEntityDescription( + key="panel_fault_battery_low", + entity_registry_enabled_default=True, + device_class=BinarySensorDeviceClass.BATTERY, + fault=ALARM_PANEL_FAULTS.BATTERY_LOW, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_battery_mising", + translation_key="panel_fault_battery_mising", + entity_registry_enabled_default=True, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.BATTERY_MISING, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_ac_fail", + translation_key="panel_fault_ac_fail", + entity_registry_enabled_default=True, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.AC_FAIL, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_phone_line_failure", + translation_key="panel_fault_phone_line_failure", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + fault=ALARM_PANEL_FAULTS.PHONE_LINE_FAILURE, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_parameter_crc_fail_in_pif", + translation_key="panel_fault_parameter_crc_fail_in_pif", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.PARAMETER_CRC_FAIL_IN_PIF, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_communication_fail_since_rps_hang_up", + translation_key="panel_fault_communication_fail_since_rps_hang_up", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.COMMUNICATION_FAIL_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_sdi_fail_since_rps_hang_up", + translation_key="panel_fault_sdi_fail_since_rps_hang_up", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.SDI_FAIL_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_user_code_tamper_since_rps_hang_up", + translation_key="panel_fault_user_code_tamper_since_rps_hang_up", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.USER_CODE_TAMPER_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_fail_to_call_rps_since_rps_hang_up", + translation_key="panel_fault_fail_to_call_rps_since_rps_hang_up", + entity_registry_enabled_default=False, + fault=ALARM_PANEL_FAULTS.FAIL_TO_CALL_RPS_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_point_bus_fail_since_rps_hang_up", + translation_key="panel_fault_point_bus_fail_since_rps_hang_up", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.POINT_BUS_FAIL_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_log_overflow", + translation_key="panel_fault_log_overflow", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.LOG_OVERFLOW, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_log_threshold", + translation_key="panel_fault_log_threshold", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.LOG_THRESHOLD, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up binary sensors for alarm points and the connection status.""" + panel = config_entry.runtime_data + + entities: list[BinarySensorEntity] = [ + PointSensor(panel, point_id, config_entry.unique_id or config_entry.entry_id) + for point_id in panel.points + ] + + entities.extend( + PanelFaultsSensor( + panel, + config_entry.unique_id or config_entry.entry_id, + fault_type, + ) + for fault_type in FAULT_TYPES + ) + + entities.extend( + AreaReadyToArmSensor( + panel, area_id, config_entry.unique_id or config_entry.entry_id, "away" + ) + for area_id in panel.areas + ) + + entities.extend( + AreaReadyToArmSensor( + panel, area_id, config_entry.unique_id or config_entry.entry_id, "home" + ) + for area_id in panel.areas + ) + + async_add_entities(entities) + + +PARALLEL_UPDATES = 0 + + +class PanelFaultsSensor(BoschAlarmEntity, BinarySensorEntity): + """A binary sensor entity for each fault type in a bosch alarm panel.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + entity_description: BoschAlarmFaultEntityDescription + + def __init__( + self, + panel: Panel, + unique_id: str, + entity_description: BoschAlarmFaultEntityDescription, + ) -> None: + """Set up a binary sensor entity for each fault type in a bosch alarm panel.""" + super().__init__(panel, unique_id, True) + self.entity_description = entity_description + self._fault_type = entity_description.fault + self._attr_unique_id = f"{unique_id}_fault_{entity_description.key}" + + @property + def is_on(self) -> bool: + """Return if this fault has occurred.""" + return self._fault_type in self.panel.panel_faults_ids + + +class AreaReadyToArmSensor(BoschAlarmAreaEntity, BinarySensorEntity): + """A binary sensor entity showing if a panel is ready to arm.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, panel: Panel, area_id: int, unique_id: str, arm_type: str + ) -> None: + """Set up a binary sensor entity for the arming status in a bosch alarm panel.""" + super().__init__(panel, area_id, unique_id, False, False, True) + self.panel = panel + self._arm_type = arm_type + self._attr_translation_key = f"area_ready_to_arm_{arm_type}" + self._attr_unique_id = f"{self._area_unique_id}_ready_to_arm_{arm_type}" + + @property + def is_on(self) -> bool: + """Return if this panel is ready to arm.""" + if self._arm_type == "away": + return self._area.all_ready + if self._arm_type == "home": + return self._area.all_ready or self._area.part_ready + return False + + +class PointSensor(BoschAlarmPointEntity, BinarySensorEntity): + """A binary sensor entity for a point in a bosch alarm panel.""" + + _attr_name = None + + def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None: + """Set up a binary sensor entity for a point in a bosch alarm panel.""" + super().__init__(panel, point_id, unique_id) + self._attr_unique_id = self._point_unique_id + + @property + def is_on(self) -> bool: + """Return if this point sensor is on.""" + return self._point.is_open() diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py index 9e664e49ca9..e492e2e7c14 100644 --- a/homeassistant/components/bosch_alarm/config_flow.py +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -6,25 +6,30 @@ import asyncio from collections.abc import Mapping import logging import ssl -from typing import Any +from typing import Any, Self from bosch_alarm_mode2 import Panel import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER, + ConfigEntryState, ConfigFlow, ConfigFlowResult, ) from homeassistant.const import ( CONF_CODE, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_PASSWORD, CONF_PORT, ) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN @@ -88,6 +93,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): """Init config flow.""" self._data: dict[str, Any] = {} + self.mac: str | None = None + self.host: str | None = None + + def is_matching(self, other_flow: Self) -> bool: + """Return True if other_flow is matching this flow.""" + return self.mac == other_flow.mac or self.host == other_flow.host async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -96,9 +107,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: + self.host = user_input[CONF_HOST] + if self.source == SOURCE_USER: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) try: # Use load_selector = 0 to fetch the panel model without authentication. - (model, serial) = await try_connect(user_input, 0) + (model, _) = await try_connect(user_input, 0) except ( OSError, ConnectionRefusedError, @@ -129,6 +143,70 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + self.mac = format_mac(discovery_info.macaddress) + self.host = discovery_info.ip + if self.hass.config_entries.flow.async_has_matching_flow(self): + return self.async_abort(reason="already_in_progress") + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data.get(CONF_MAC) == self.mac: + result = self.hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_HOST: discovery_info.ip, + }, + ) + if result: + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + if entry.data[CONF_HOST] == discovery_info.ip: + if ( + not entry.data.get(CONF_MAC) + and entry.state is ConfigEntryState.LOADED + ): + result = self.hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_MAC: self.mac, + }, + ) + if result: + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + try: + # Use load_selector = 0 to fetch the panel model without authentication. + (model, _) = await try_connect( + {CONF_HOST: discovery_info.ip, CONF_PORT: 7700}, 0 + ) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + asyncio.exceptions.TimeoutError, + ): + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + self.context["title_placeholders"] = { + "model": model, + "host": discovery_info.ip, + } + self._data = { + CONF_HOST: discovery_info.ip, + CONF_MAC: self.mac, + CONF_MODEL: model, + CONF_PORT: 7700, + } + + return await self.async_step_auth() + async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -172,7 +250,7 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): else: if serial_number: await self.async_set_unique_id(str(serial_number)) - if self.source == SOURCE_USER: + if self.source in (SOURCE_USER, SOURCE_DHCP): if serial_number: self._abort_if_unique_id_configured() else: @@ -184,6 +262,7 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): ) 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, diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py index 7205831391c..33ec0ae526a 100644 --- a/homeassistant/components/bosch_alarm/const.py +++ b/homeassistant/components/bosch_alarm/const.py @@ -1,6 +1,9 @@ """Constants for the Bosch Alarm integration.""" DOMAIN = "bosch_alarm" -HISTORY_ATTR = "history" +ATTR_HISTORY = "history" CONF_INSTALLER_CODE = "installer_code" CONF_USER_CODE = "user_code" +ATTR_DATETIME = "datetime" +SERVICE_SET_DATE_TIME = "set_date_time" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/bosch_alarm/diagnostics.py b/homeassistant/components/bosch_alarm/diagnostics.py index 2e93052ea95..ea9988960b5 100644 --- a/homeassistant/components/bosch_alarm/diagnostics.py +++ b/homeassistant/components/bosch_alarm/diagnostics.py @@ -6,8 +6,8 @@ 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 +from .types import BoschAlarmConfigEntry TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD] diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py index f74634125c4..537ee412e47 100644 --- a/homeassistant/components/bosch_alarm/entity.py +++ b/homeassistant/components/bosch_alarm/entity.py @@ -17,9 +17,13 @@ class BoschAlarmEntity(Entity): _attr_has_entity_name = True - def __init__(self, panel: Panel, unique_id: str) -> None: + def __init__( + self, panel: Panel, unique_id: str, observe_faults: bool = False + ) -> None: """Set up a entity for a bosch alarm panel.""" self.panel = panel + self._observe_faults = observe_faults + self._attr_should_poll = False self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, name=f"Bosch {panel.model}", @@ -34,10 +38,14 @@ class BoschAlarmEntity(Entity): async def async_added_to_hass(self) -> None: """Observe state changes.""" self.panel.connection_status_observer.attach(self.schedule_update_ha_state) + if self._observe_faults: + self.panel.faults_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) + if self._observe_faults: + self.panel.faults_observer.attach(self.schedule_update_ha_state) class BoschAlarmAreaEntity(BoschAlarmEntity): @@ -86,3 +94,84 @@ class BoschAlarmAreaEntity(BoschAlarmEntity): self._area.ready_observer.detach(self.schedule_update_ha_state) if self._observe_status: self._area.status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmPointEntity(BoschAlarmEntity): + """A base entity for point related entities within a bosch alarm panel.""" + + def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None: + """Set up a area related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._point_id = point_id + self._point_unique_id = f"{unique_id}_point_{point_id}" + self._point = panel.points[point_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._point_unique_id)}, + name=self._point.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() + self._point.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() + self._point.status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmDoorEntity(BoschAlarmEntity): + """A base entity for area related entities within a bosch alarm panel.""" + + def __init__(self, panel: Panel, door_id: int, unique_id: str) -> None: + """Set up a area related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._door_id = door_id + self._door = panel.doors[door_id] + self._door_unique_id = f"{unique_id}_door_{door_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._door_unique_id)}, + name=self._door.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() + self._door.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() + self._door.status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmOutputEntity(BoschAlarmEntity): + """A base entity for area related entities within a bosch alarm panel.""" + + def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None: + """Set up a output related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._output_id = output_id + self._output = panel.outputs[output_id] + self._output_unique_id = f"{unique_id}_output_{output_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._output_unique_id)}, + name=self._output.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() + self._output.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() + self._output.status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/icons.json b/homeassistant/components/bosch_alarm/icons.json index 1e207310713..c396350e37e 100644 --- a/homeassistant/components/bosch_alarm/icons.json +++ b/homeassistant/components/bosch_alarm/icons.json @@ -1,8 +1,80 @@ { + "services": { + "set_date_time": { + "service": "mdi:clock-edit" + } + }, "entity": { "sensor": { + "alarms_gas": { + "default": "mdi:alert-circle" + }, + "alarms_fire": { + "default": "mdi:alert-circle" + }, + "alarms_burglary": { + "default": "mdi:alert-circle" + }, "faulting_points": { - "default": "mdi:alert-circle-outline" + "default": "mdi:alert-circle" + } + }, + "switch": { + "locked": { + "default": "mdi:lock", + "state": { + "off": "mdi:lock-open" + } + }, + "secured": { + "default": "mdi:lock", + "state": { + "off": "mdi:lock-open" + } + }, + "cycling": { + "default": "mdi:lock", + "state": { + "on": "mdi:lock-open" + } + } + }, + "binary_sensor": { + "panel_fault_parameter_crc_fail_in_pif": { + "default": "mdi:alert-circle" + }, + "panel_fault_phone_line_failure": { + "default": "mdi:alert-circle" + }, + "panel_fault_sdi_fail_since_rps_hang_up": { + "default": "mdi:alert-circle" + }, + "panel_fault_user_code_tamper_since_rps_hang_up": { + "default": "mdi:alert-circle" + }, + "panel_fault_fail_to_call_rps_since_rps_hang_up": { + "default": "mdi:alert-circle" + }, + "panel_fault_point_bus_fail_since_rps_hang_up": { + "default": "mdi:alert-circle" + }, + "panel_fault_log_overflow": { + "default": "mdi:alert-circle" + }, + "panel_fault_log_threshold": { + "default": "mdi:alert-circle" + }, + "area_ready_to_arm_away": { + "default": "mdi:shield", + "state": { + "on": "mdi:shield-lock" + } + }, + "area_ready_to_arm_home": { + "default": "mdi:shield", + "state": { + "on": "mdi:shield-home" + } } } } diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json index eefcc400ee7..160d6141959 100644 --- a/homeassistant/components/bosch_alarm/manifest.json +++ b/homeassistant/components/bosch_alarm/manifest.json @@ -3,6 +3,11 @@ "name": "Bosch Alarm", "codeowners": ["@mag1024", "@sanjay900"], "config_flow": true, + "dhcp": [ + { + "macaddress": "000463*" + } + ], "documentation": "https://www.home-assistant.io/integrations/bosch_alarm", "integration_type": "device", "iot_class": "local_push", diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 3a64667a407..474dc348fd8 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -13,10 +13,7 @@ rules: config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: | - No custom actions are defined. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -29,25 +26,22 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - No custom actions are defined. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: todo docs-installation-parameters: todo entity-unavailable: todo integration-owner: done log-when-unavailable: todo - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done # Gold devices: done diagnostics: todo - discovery-update-info: todo - discovery: todo + discovery-update-info: done + discovery: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py index 3d61c72a883..479aaa03049 100644 --- a/homeassistant/components/bosch_alarm/sensor.py +++ b/homeassistant/components/bosch_alarm/sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.const import ALARM_MEMORY_PRIORITIES from bosch_alarm_mode2.panel import Area from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -15,18 +16,53 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BoschAlarmConfigEntry from .entity import BoschAlarmAreaEntity +ALARM_TYPES = { + "burglary": { + ALARM_MEMORY_PRIORITIES.BURGLARY_SUPERVISORY: "supervisory", + ALARM_MEMORY_PRIORITIES.BURGLARY_TROUBLE: "trouble", + ALARM_MEMORY_PRIORITIES.BURGLARY_ALARM: "alarm", + }, + "gas": { + ALARM_MEMORY_PRIORITIES.GAS_SUPERVISORY: "supervisory", + ALARM_MEMORY_PRIORITIES.GAS_TROUBLE: "trouble", + ALARM_MEMORY_PRIORITIES.GAS_ALARM: "alarm", + }, + "fire": { + ALARM_MEMORY_PRIORITIES.FIRE_SUPERVISORY: "supervisory", + ALARM_MEMORY_PRIORITIES.FIRE_TROUBLE: "trouble", + ALARM_MEMORY_PRIORITIES.FIRE_ALARM: "alarm", + }, +} + @dataclass(kw_only=True, frozen=True) class BoschAlarmSensorEntityDescription(SensorEntityDescription): """Describes Bosch Alarm sensor entity.""" - value_fn: Callable[[Area], int] + value_fn: Callable[[Area], str | int] observe_alarms: bool = False observe_ready: bool = False observe_status: bool = False +def priority_value_fn(priority_info: dict[int, str]) -> Callable[[Area], str]: + """Build a value_fn for a given priority type.""" + return lambda area: next( + (key for priority, key in priority_info.items() if priority in area.alarms_ids), + "no_issues", + ) + + SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [ + *[ + BoschAlarmSensorEntityDescription( + key=f"alarms_{key}", + translation_key=f"alarms_{key}", + value_fn=priority_value_fn(priority_type), + observe_alarms=True, + ) + for key, priority_type in ALARM_TYPES.items() + ], BoschAlarmSensorEntityDescription( key="faulting_points", translation_key="faulting_points", @@ -81,6 +117,6 @@ class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity): self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}" @property - def native_value(self) -> int: + def native_value(self) -> str | int: """Return the state of the sensor.""" return self.entity_description.value_fn(self._area) diff --git a/homeassistant/components/bosch_alarm/services.py b/homeassistant/components/bosch_alarm/services.py new file mode 100644 index 00000000000..5d9a5f5645f --- /dev/null +++ b/homeassistant/components/bosch_alarm/services.py @@ -0,0 +1,77 @@ +"""Services for the bosch_alarm integration.""" + +from __future__ import annotations + +import asyncio +import datetime as dt +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.util import dt as dt_util + +from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME +from .types import BoschAlarmConfigEntry + + +def validate_datetime(value: Any) -> dt.datetime: + """Validate that a provided datetime is supported on a bosch alarm panel.""" + date_val = cv.datetime(value) + if date_val.year < 2010: + raise vol.RangeInvalid("datetime must be after 2009") + + if date_val.year > 2037: + raise vol.RangeInvalid("datetime must be before 2038") + + return date_val + + +SET_DATE_TIME_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_DATETIME): validate_datetime, + } +) + + +async def async_set_panel_date(call: ServiceCall) -> None: + """Set the date and time on a bosch alarm panel.""" + config_entry: BoschAlarmConfigEntry | None + value: dt.datetime = call.data.get(ATTR_DATETIME, dt_util.now()) + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + if not (config_entry := call.hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": entry_id}, + ) + if config_entry.state is not ConfigEntryState.LOADED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + panel = config_entry.runtime_data + try: + await panel.set_panel_date(value) + except asyncio.InvalidStateError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"target": config_entry.title}, + ) from err + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the bosch alarm integration.""" + + hass.services.async_register( + DOMAIN, + SERVICE_SET_DATE_TIME, + async_set_panel_date, + schema=SET_DATE_TIME_SCHEMA, + ) diff --git a/homeassistant/components/bosch_alarm/services.yaml b/homeassistant/components/bosch_alarm/services.yaml new file mode 100644 index 00000000000..a3e8d800005 --- /dev/null +++ b/homeassistant/components/bosch_alarm/services.yaml @@ -0,0 +1,12 @@ +set_date_time: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: bosch_alarm + datetime: + required: false + example: "2025-05-10 00:00:00" + selector: + datetime: diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index 6b916dad4fa..76c15a0a5c7 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{model} ({host})", "step": { "user": { "data": { @@ -42,6 +43,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", @@ -49,15 +51,130 @@ } }, "exceptions": { + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "connection_error": { + "message": "Could not connect to \"{target}\"." + }, + "unknown_error": { + "message": "An unknown error occurred while setting the date and time on \"{target}\"." + }, "cannot_connect": { "message": "Could not connect to panel." }, "authentication_failed": { "message": "Incorrect credentials for panel." + }, + "incorrect_door_state": { + "message": "Door cannot be manipulated while it is momentarily unlocked." + } + }, + "services": { + "set_date_time": { + "name": "Set date & time", + "description": "Sets the date and time on the alarm panel.", + "fields": { + "datetime": { + "name": "Date & time", + "description": "The date and time to set. The time zone of the Home Assistant instance is assumed. If omitted, the current date and time is used." + }, + "config_entry_id": { + "name": "Config entry", + "description": "The Bosch Alarm integration ID." + } + } } }, "entity": { + "binary_sensor": { + "panel_fault_battery_mising": { + "name": "Battery missing" + }, + "panel_fault_ac_fail": { + "name": "AC Failure" + }, + "panel_fault_parameter_crc_fail_in_pif": { + "name": "CRC failure in panel configuration" + }, + "panel_fault_phone_line_failure": { + "name": "Phone line failure" + }, + "panel_fault_sdi_fail_since_rps_hang_up": { + "name": "SDI failure since last RPS connection" + }, + "panel_fault_user_code_tamper_since_rps_hang_up": { + "name": "User code tamper since last RPS connection" + }, + "panel_fault_fail_to_call_rps_since_rps_hang_up": { + "name": "Failure to call RPS since last RPS connection" + }, + "panel_fault_point_bus_fail_since_rps_hang_up": { + "name": "Point bus failure since last RPS connection" + }, + "panel_fault_log_overflow": { + "name": "Log overflow" + }, + "panel_fault_log_threshold": { + "name": "Log threshold reached" + }, + "area_ready_to_arm_away": { + "name": "Area ready to arm away", + "state": { + "on": "Ready", + "off": "Not ready" + } + }, + "area_ready_to_arm_home": { + "name": "Area ready to arm home", + "state": { + "on": "Ready", + "off": "Not ready" + } + } + }, + "switch": { + "secured": { + "name": "Secured" + }, + "cycling": { + "name": "Momentarily unlocked" + }, + "locked": { + "name": "Locked" + } + }, "sensor": { + "alarms_gas": { + "name": "Gas alarm issues", + "state": { + "supervisory": "Supervisory", + "trouble": "Trouble", + "alarm": "Alarm", + "no_issues": "No issues" + } + }, + "alarms_fire": { + "name": "Fire alarm issues", + "state": { + "supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]", + "trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]", + "alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]", + "no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]" + } + }, + "alarms_burglary": { + "name": "Burglary alarm issues", + "state": { + "supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]", + "trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]", + "alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]", + "no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]" + } + }, "faulting_points": { "name": "Faulting points", "unit_of_measurement": "points" diff --git a/homeassistant/components/bosch_alarm/switch.py b/homeassistant/components/bosch_alarm/switch.py new file mode 100644 index 00000000000..9d6e48d591d --- /dev/null +++ b/homeassistant/components/bosch_alarm/switch.py @@ -0,0 +1,150 @@ +"""Support for Bosch Alarm Panel outputs and doors as switches.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.panel import Door + +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 . import BoschAlarmConfigEntry +from .const import DOMAIN +from .entity import BoschAlarmDoorEntity, BoschAlarmOutputEntity + + +@dataclass(kw_only=True, frozen=True) +class BoschAlarmSwitchEntityDescription(SwitchEntityDescription): + """Describes Bosch Alarm door entity.""" + + value_fn: Callable[[Door], bool] + on_fn: Callable[[Panel, int], Coroutine[Any, Any, None]] + off_fn: Callable[[Panel, int], Coroutine[Any, Any, None]] + + +DOOR_SWITCH_TYPES: list[BoschAlarmSwitchEntityDescription] = [ + BoschAlarmSwitchEntityDescription( + key="locked", + translation_key="locked", + value_fn=lambda door: door.is_locked(), + on_fn=lambda panel, door_id: panel.door_relock(door_id), + off_fn=lambda panel, door_id: panel.door_unlock(door_id), + ), + BoschAlarmSwitchEntityDescription( + key="secured", + translation_key="secured", + value_fn=lambda door: door.is_secured(), + on_fn=lambda panel, door_id: panel.door_secure(door_id), + off_fn=lambda panel, door_id: panel.door_unsecure(door_id), + ), + BoschAlarmSwitchEntityDescription( + key="cycling", + translation_key="cycling", + value_fn=lambda door: door.is_cycling(), + on_fn=lambda panel, door_id: panel.door_cycle(door_id), + off_fn=lambda panel, door_id: panel.door_relock(door_id), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switch entities for outputs.""" + + panel = config_entry.runtime_data + entities: list[SwitchEntity] = [ + PanelOutputEntity( + panel, output_id, config_entry.unique_id or config_entry.entry_id + ) + for output_id in panel.outputs + ] + + entities.extend( + PanelDoorEntity( + panel, + door_id, + config_entry.unique_id or config_entry.entry_id, + entity_description, + ) + for door_id in panel.doors + for entity_description in DOOR_SWITCH_TYPES + ) + + async_add_entities(entities) + + +PARALLEL_UPDATES = 0 + + +class PanelDoorEntity(BoschAlarmDoorEntity, SwitchEntity): + """A switch entity for a door on a bosch alarm panel.""" + + entity_description: BoschAlarmSwitchEntityDescription + + def __init__( + self, + panel: Panel, + door_id: int, + unique_id: str, + entity_description: BoschAlarmSwitchEntityDescription, + ) -> None: + """Set up a switch entity for a door on a bosch alarm panel.""" + super().__init__(panel, door_id, unique_id) + self.entity_description = entity_description + self._attr_unique_id = f"{self._door_unique_id}_{entity_description.key}" + + @property + def is_on(self) -> bool: + """Return the value function.""" + return self.entity_description.value_fn(self._door) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Run the on function.""" + # If the door is currently cycling, we can't send it any other commands until it is done + if self._door.is_cycling(): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="incorrect_door_state" + ) + await self.entity_description.on_fn(self.panel, self._door_id) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Run the off function.""" + # If the door is currently cycling, we can't send it any other commands until it is done + if self._door.is_cycling(): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="incorrect_door_state" + ) + await self.entity_description.off_fn(self.panel, self._door_id) + + +class PanelOutputEntity(BoschAlarmOutputEntity, SwitchEntity): + """An output entity for a bosch alarm panel.""" + + _attr_name = None + + def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None: + """Set up an output entity for a bosch alarm panel.""" + super().__init__(panel, output_id, unique_id) + self._attr_unique_id = self._output_unique_id + + @property + def is_on(self) -> bool: + """Check if this entity is on.""" + return self._output.is_active() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on this output.""" + await self.panel.set_output_active(self._output_id) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off this output.""" + await self.panel.set_output_inactive(self._output_id) diff --git a/homeassistant/components/bosch_alarm/types.py b/homeassistant/components/bosch_alarm/types.py new file mode 100644 index 00000000000..7d45094b208 --- /dev/null +++ b/homeassistant/components/bosch_alarm/types.py @@ -0,0 +1,7 @@ +"""Types for the Bosch Alarm integration.""" + +from bosch_alarm_mode2 import Panel + +from homeassistant.config_entries import ConfigEntry + +type BoschAlarmConfigEntry = ConfigEntry[Panel] diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 6dd2d36351c..6c0b34c66f0 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -10,7 +10,12 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import BringConfigEntry, BringDataUpdateCoordinator +from .coordinator import ( + BringActivityCoordinator, + BringConfigEntry, + BringCoordinators, + BringDataUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] @@ -26,7 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo coordinator = BringDataUpdateCoordinator(hass, entry, bring) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + activity_coordinator = BringActivityCoordinator(hass, entry, coordinator) + await activity_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = BringCoordinators(coordinator, activity_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index e1f9fa45ac8..0a8d980a6aa 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -30,7 +30,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] +type BringConfigEntry = ConfigEntry[BringCoordinators] + + +@dataclass +class BringCoordinators: + """Data class holding coordinators.""" + + data: BringDataUpdateCoordinator + activity: BringActivityCoordinator @dataclass(frozen=True) @@ -39,17 +47,28 @@ class BringData(DataClassORJSONMixin): lst: BringList content: BringItemsResponse + + +@dataclass(frozen=True) +class BringActivityData(DataClassORJSONMixin): + """Coordinator data class.""" + activity: BringActivityResponse users: BringUsersResponse -class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): - """A Bring Data Update Coordinator.""" +class BringBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Bring base coordinator.""" config_entry: BringConfigEntry - user_settings: BringUserSettingsResponse lists: list[BringList] + +class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]): + """A Bring Data Update Coordinator.""" + + user_settings: BringUserSettingsResponse + def __init__( self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring ) -> None: @@ -90,16 +109,19 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): current_lists := {lst.listUuid for lst in self.lists} ): self._purge_deleted_lists() + new_lists = current_lists - self.previous_lists self.previous_lists = current_lists list_dict: dict[str, BringData] = {} for lst in self.lists: - if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: + if ( + (ctx := set(self.async_contexts())) + and lst.listUuid not in ctx + and lst.listUuid not in new_lists + ): continue try: items = await self.bring.get_list(lst.listUuid) - activity = await self.bring.get_activity(lst.listUuid) - users = await self.bring.get_list_users(lst.listUuid) except BringRequestException as e: raise UpdateFailed( translation_domain=DOMAIN, @@ -111,7 +133,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_parse_exception", ) from e else: - list_dict[lst.listUuid] = BringData(lst, items, activity, users) + list_dict[lst.listUuid] = BringData(lst, items) return list_dict @@ -156,3 +178,60 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): device_reg.async_update_device( device.id, remove_config_entry_id=self.config_entry.entry_id ) + + +class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]): + """A Bring Activity Data Update Coordinator.""" + + user_settings: BringUserSettingsResponse + + def __init__( + self, + hass: HomeAssistant, + config_entry: BringConfigEntry, + coordinator: BringDataUpdateCoordinator, + ) -> None: + """Initialize the Bring Activity data coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=10), + ) + + self.coordinator = coordinator + self.lists = coordinator.lists + + async def _async_update_data(self) -> dict[str, BringActivityData]: + """Fetch activity data from bring.""" + + list_dict: dict[str, BringActivityData] = {} + for lst in self.lists: + if ( + ctx := set(self.coordinator.async_contexts()) + ) and lst.listUuid not in ctx: + continue + try: + activity = await self.coordinator.bring.get_activity(lst.listUuid) + users = await self.coordinator.bring.get_list_users(lst.listUuid) + except BringAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_EMAIL: self.coordinator.bring.mail}, + ) from e + except BringRequestException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except BringParseException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + else: + list_dict[lst.listUuid] = BringActivityData(activity, users) + + return list_dict diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index e5cafd30ab5..2f5a0cae504 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -20,9 +20,12 @@ async def async_get_config_entry_diagnostics( return { "data": { - k: async_redact_data(v.to_dict(), TO_REDACT) - for k, v in config_entry.runtime_data.data.items() + k: v.to_dict() for k, v in config_entry.runtime_data.data.data.items() }, - "lists": [lst.to_dict() for lst in config_entry.runtime_data.lists], - "user_settings": config_entry.runtime_data.user_settings.to_dict(), + "activity": { + k: async_redact_data(v.to_dict(), TO_REDACT) + for k, v in config_entry.runtime_data.activity.data.items() + }, + "lists": [lst.to_dict() for lst in config_entry.runtime_data.data.lists], + "user_settings": config_entry.runtime_data.data.user_settings.to_dict(), } diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index ee90f22beef..1bb49afeb5d 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -8,17 +8,17 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import BringDataUpdateCoordinator +from .coordinator import BringBaseCoordinator -class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): +class BringBaseEntity(CoordinatorEntity[BringBaseCoordinator]): """Bring base entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: BringDataUpdateCoordinator, + coordinator: BringBaseCoordinator, bring_list: BringList, ) -> None: """Initialize the entity.""" @@ -34,5 +34,7 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): }, manufacturer="Bring! Labs AG", model="Bring! Grocery Shopping List", - configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}", + configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}" + if bring_list in self.coordinator.lists + else None, ) diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index 403856405ce..e9e286dccf0 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BringConfigEntry -from .coordinator import BringDataUpdateCoordinator +from .coordinator import BringActivityCoordinator from .entity import BringBaseEntity PARALLEL_UPDATES = 0 @@ -32,18 +32,18 @@ async def async_setup_entry( """Add event entities.""" nonlocal lists_added - if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + if new_lists := {lst.listUuid for lst in coordinator.data.lists} - lists_added: async_add_entities( BringEventEntity( - coordinator, + coordinator.activity, bring_list, ) - for bring_list in coordinator.lists + for bring_list in coordinator.data.lists if bring_list.listUuid in new_lists ) lists_added |= new_lists - coordinator.async_add_listener(add_entities) + coordinator.activity.async_add_listener(add_entities) add_entities() @@ -51,10 +51,11 @@ class BringEventEntity(BringBaseEntity, EventEntity): """An event entity.""" _attr_translation_key = "activities" + coordinator: BringActivityCoordinator def __init__( self, - coordinator: BringDataUpdateCoordinator, + coordinator: BringActivityCoordinator, bring_list: BringList, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 2a09d574607..88399ea26f7 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.data lists_added: set[str] = set() @callback @@ -117,6 +117,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity): """A sensor entity.""" entity_description: BringSensorEntityDescription + coordinator: BringDataUpdateCoordinator def __init__( self, diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index d1eb9e78341..04902f3e724 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -44,7 +44,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.data lists_added: set[str] = set() @callback @@ -88,6 +88,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) + coordinator: BringDataUpdateCoordinator def __init__( self, coordinator: BringDataUpdateCoordinator, bring_list: BringList @@ -107,7 +108,9 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): description=item.specification, status=TodoItemStatus.NEEDS_ACTION, ) - for item in self.bring_list.content.items.purchase + for item in sorted( + self.bring_list.content.items.purchase, key=lambda i: i.itemId + ) ), *( TodoItem( diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 5c1334c8029..d0e0bd0b1d0 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.3.9", "icalendar==6.1.0"] + "requirements": ["caldav==1.6.0", "icalendar==6.1.0"] } diff --git a/homeassistant/components/cambridge_audio/icons.json b/homeassistant/components/cambridge_audio/icons.json index b4346a7fe8e..dda7d71e506 100644 --- a/homeassistant/components/cambridge_audio/icons.json +++ b/homeassistant/components/cambridge_audio/icons.json @@ -11,6 +11,13 @@ }, "audio_output": { "default": "mdi:audio-input-stereo-minijack" + }, + "control_bus_mode": { + "default": "mdi:audio-video-off", + "state": { + "amplifier": "mdi:speaker", + "receiver": "mdi:audio-video" + } } }, "switch": { diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index 5322ae7d9a2..e8f92c0b25c 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -11,6 +11,7 @@ from aiostreammagic import ( StreamMagicClient, TransportControl, ) +from aiostreammagic.models import ControlBusMode from homeassistant.components.media_player import ( BrowseMedia, @@ -91,6 +92,8 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): features = BASE_FEATURES if self.client.state.pre_amp_mode: features |= PREAMP_FEATURES + if self.client.state.control_bus == ControlBusMode.AMPLIFIER: + features |= MediaPlayerEntityFeature.VOLUME_STEP if TransportControl.PLAY_PAUSE in controls: features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE for control in controls: diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py index e7d9136711f..cdc163f555d 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from aiostreammagic import StreamMagicClient -from aiostreammagic.models import DisplayBrightness +from aiostreammagic.models import ControlBusMode, DisplayBrightness from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -76,6 +76,20 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = ( value_fn=_audio_output_value_fn, set_value_fn=_audio_output_set_value_fn, ), + CambridgeAudioSelectEntityDescription( + key="control_bus_mode", + translation_key="control_bus_mode", + options=[ + ControlBusMode.AMPLIFIER.value, + ControlBusMode.RECEIVER.value, + ControlBusMode.OFF.value, + ], + entity_category=EntityCategory.CONFIG, + value_fn=lambda client: client.state.control_bus, + set_value_fn=lambda client, value: client.set_control_bus_mode( + ControlBusMode(value) + ), + ), ) diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json index 6041232fe65..e2c89bcbbb0 100644 --- a/homeassistant/components/cambridge_audio/strings.json +++ b/homeassistant/components/cambridge_audio/strings.json @@ -46,6 +46,14 @@ }, "audio_output": { "name": "Audio output" + }, + "control_bus_mode": { + "name": "Control Bus mode", + "state": { + "amplifier": "Amplifier", + "receiver": "Receiver", + "off": "[%key:common::state::off%]" + } } }, "switch": { diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index aa5d766c874..ee9d1cbc94f 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -55,13 +55,11 @@ from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, all_with_deprecated_constants, check_if_deprecated_constant, - deprecated_function, dir_with_deprecated_constants, ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.network import get_url from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolDictType @@ -86,18 +84,15 @@ from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401 from .webrtc import ( DATA_ICE_SERVERS, - CameraWebRTCLegacyProvider, CameraWebRTCProvider, - WebRTCAnswer, + WebRTCAnswer, # noqa: F401 WebRTCCandidate, # noqa: F401 WebRTCClientConfiguration, - WebRTCError, + WebRTCError, # noqa: F401 WebRTCMessage, # noqa: F401 WebRTCSendMessage, - async_get_supported_legacy_provider, async_get_supported_provider, async_register_ice_servers, - async_register_rtsp_to_web_rtc_provider, # noqa: F401 async_register_webrtc_provider, # noqa: F401 async_register_ws, ) @@ -436,7 +431,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CACHED_PROPERTIES_WITH_ATTR_ = { "brand", "frame_interval", - "frontend_stream_type", "is_on", "is_recording", "is_streaming", @@ -456,8 +450,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Entity Properties _attr_brand: str | None = None _attr_frame_interval: float = MIN_STREAM_INTERVAL - # Deprecated in 2024.12. Remove in 2025.6 - _attr_frontend_stream_type: StreamType | None _attr_is_on: bool = True _attr_is_recording: bool = False _attr_is_streaming: bool = False @@ -480,24 +472,10 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self.async_update_token() self._create_stream_lock: asyncio.Lock | None = None self._webrtc_provider: CameraWebRTCProvider | None = None - self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None - self._supports_native_sync_webrtc = ( - type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer - ) self._supports_native_async_webrtc = ( type(self).async_handle_async_webrtc_offer != Camera.async_handle_async_webrtc_offer ) - self._deprecate_attr_frontend_stream_type_logged = False - if type(self).frontend_stream_type != Camera.frontend_stream_type: - report_usage( - ( - f"is overwriting the 'frontend_stream_type' property in the {type(self).__name__} class," - " which is deprecated and will be removed in Home Assistant 2025.6, " - ), - core_integration_behavior=ReportBehavior.ERROR, - exclude_integrations={DOMAIN}, - ) @cached_property def entity_picture(self) -> str: @@ -559,40 +537,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the interval between frames of the mjpeg stream.""" return self._attr_frame_interval - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera. - - A camera may have a single stream type which is used to inform the - frontend which camera attributes and player to use. The default type - is to use HLS, and components can override to change the type. - """ - # Deprecated in 2024.12. Remove in 2025.6 - # Use the camera_capabilities instead - if hasattr(self, "_attr_frontend_stream_type"): - if not self._deprecate_attr_frontend_stream_type_logged: - report_usage( - ( - f"is setting the '_attr_frontend_stream_type' attribute in the {type(self).__name__} class," - " which is deprecated and will be removed in Home Assistant 2025.6, " - ), - core_integration_behavior=ReportBehavior.ERROR, - exclude_integrations={DOMAIN}, - ) - - self._deprecate_attr_frontend_stream_type_logged = True - return self._attr_frontend_stream_type - if CameraEntityFeature.STREAM not in self.supported_features_compat: - return None - if ( - self._webrtc_provider - or self._legacy_webrtc_provider - or self._supports_native_sync_webrtc - or self._supports_native_async_webrtc - ): - return StreamType.WEB_RTC - return StreamType.HLS - @property def available(self) -> bool: """Return True if entity is available.""" @@ -631,15 +575,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ return None - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - """Handle the WebRTC offer and return an answer. - - This is used by cameras with CameraEntityFeature.STREAM - and StreamType.WEB_RTC. - - Integrations can override with a native WebRTC implementation. - """ - async def async_handle_async_webrtc_offer( self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage ) -> None: @@ -652,56 +587,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Integrations can override with a native WebRTC implementation. """ - if self._supports_native_sync_webrtc: - try: - answer = await deprecated_function( - "async_handle_async_webrtc_offer", - breaks_in_ha_version="2025.6", - )(self.async_handle_web_rtc_offer)(offer_sdp) - except ValueError as ex: - _LOGGER.error("Error handling WebRTC offer: %s", ex) - send_message( - WebRTCError( - "webrtc_offer_failed", - str(ex), - ) - ) - except TimeoutError: - # This catch was already here and should stay through the deprecation - _LOGGER.error("Timeout handling WebRTC offer") - send_message( - WebRTCError( - "webrtc_offer_failed", - "Timeout handling WebRTC offer", - ) - ) - else: - if answer: - send_message(WebRTCAnswer(answer)) - else: - _LOGGER.error("Error handling WebRTC offer: No answer") - send_message( - WebRTCError( - "webrtc_offer_failed", - "No answer on WebRTC offer", - ) - ) - return - if self._webrtc_provider: await self._webrtc_provider.async_handle_async_webrtc_offer( self, offer_sdp, session_id, send_message ) return - if self._legacy_webrtc_provider and ( - answer := await self._legacy_webrtc_provider.async_handle_web_rtc_offer( - self, offer_sdp - ) - ): - send_message(WebRTCAnswer(answer)) - else: - raise HomeAssistantError("Camera does not support WebRTC") + raise HomeAssistantError("Camera does not support WebRTC") def camera_image( self, width: int | None = None, height: int | None = None @@ -797,9 +689,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if motion_detection_enabled := self.motion_detection_enabled: attrs["motion_detection"] = motion_detection_enabled - if frontend_stream_type := self.frontend_stream_type: - attrs["frontend_stream_type"] = frontend_stream_type - return attrs @callback @@ -823,28 +712,17 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): providers or inputs to the state attributes change. """ old_provider = self._webrtc_provider - old_legacy_provider = self._legacy_webrtc_provider new_provider = None - new_legacy_provider = None # Skip all providers if the camera has a native WebRTC implementation - if not ( - self._supports_native_sync_webrtc or self._supports_native_async_webrtc - ): + if not self._supports_native_async_webrtc: # Camera doesn't have a native WebRTC implementation new_provider = await self._async_get_supported_webrtc_provider( async_get_supported_provider ) - if new_provider is None: - # Only add the legacy provider if the new provider is not available - new_legacy_provider = await self._async_get_supported_webrtc_provider( - async_get_supported_legacy_provider - ) - - if old_provider != new_provider or old_legacy_provider != new_legacy_provider: + if old_provider != new_provider: self._webrtc_provider = new_provider - self._legacy_webrtc_provider = new_legacy_provider self._invalidate_camera_capabilities_cache() if write_state: self.async_write_ha_state() @@ -869,20 +747,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the WebRTC client configuration and extend it with the registered ice servers.""" config = self._async_get_webrtc_client_configuration() - if not self._supports_native_sync_webrtc: - # Until 2024.11, the frontend was not resolving any ice servers - # The async approach was added 2024.11 and new integrations need to use it - ice_servers = [ - server - for servers in self.hass.data.get(DATA_ICE_SERVERS, []) - for server in servers() - ] - config.configuration.ice_servers.extend(ice_servers) - - config.get_candidates_upfront = ( - self._supports_native_sync_webrtc - or self._legacy_webrtc_provider is not None - ) + ice_servers = [ + server + for servers in self.hass.data.get(DATA_ICE_SERVERS, []) + for server in servers() + ] + config.configuration.ice_servers.extend(ice_servers) return config @@ -912,13 +782,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the camera capabilities.""" frontend_stream_types = set() if CameraEntityFeature.STREAM in self.supported_features_compat: - if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: + if self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) else: frontend_stream_types.add(StreamType.HLS) - if self._webrtc_provider or self._legacy_webrtc_provider: + if self._webrtc_provider: frontend_stream_types.add(StreamType.WEB_RTC) return CameraCapabilities(frontend_stream_types) diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 4a7e9aafc6e..9176c5ad84a 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -46,10 +46,6 @@ } } } - }, - "legacy_webrtc_provider": { - "title": "Detected use of legacy WebRTC provider registered by {legacy_integration}", - "description": "The {legacy_integration} integration has registered a legacy WebRTC provider. Home Assistant prefers using the built-in modern WebRTC provider registered by the {builtin_integration} integration.\n\nBenefits of the built-in integration are:\n\n- The camera stream is started faster.\n- More camera devices are supported.\n\nTo fix this issue, you can either keep using the built-in modern WebRTC provider and remove the {legacy_integration} integration or remove the {builtin_integration} integration to use the legacy provider, and then restart Home Assistant." } }, "services": { diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 3630acf1cfe..9ad50430f83 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable, Iterable from dataclasses import asdict, dataclass, field from functools import cache, partial, wraps import logging -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any from mashumaro import MissingField import voluptuous as vol @@ -22,8 +22,7 @@ from webrtc_models import ( from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.deprecation import deprecated_function +from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey from homeassistant.util.ulid import ulid @@ -39,9 +38,6 @@ _LOGGER = logging.getLogger(__name__) DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey( "camera_webrtc_providers" ) -DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[dict[str, CameraWebRTCLegacyProvider]] = HassKey( - "camera_webrtc_legacy_providers" -) DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey( "camera_webrtc_ice_servers" ) @@ -115,13 +111,11 @@ class WebRTCClientConfiguration: configuration: RTCConfiguration = field(default_factory=RTCConfiguration) data_channel: str | None = None - get_candidates_upfront: bool = False def to_frontend_dict(self) -> dict[str, Any]: """Return a dict that can be used by the frontend.""" data: dict[str, Any] = { "configuration": self.configuration.to_dict(), - "getCandidatesUpfront": self.get_candidates_upfront, } if self.data_channel is not None: data["dataChannel"] = self.data_channel @@ -163,18 +157,6 @@ class CameraWebRTCProvider(ABC): return ## This is an optional method so we need a default here. -class CameraWebRTCLegacyProvider(Protocol): - """WebRTC provider.""" - - async def async_is_supported(self, stream_source: str) -> bool: - """Determine if the provider supports the stream source.""" - - async def async_handle_web_rtc_offer( - self, camera: Camera, offer_sdp: str - ) -> str | None: - """Handle the WebRTC offer and return an answer.""" - - @callback def async_register_webrtc_provider( hass: HomeAssistant, @@ -204,8 +186,6 @@ def async_register_webrtc_provider( async def _async_refresh_providers(hass: HomeAssistant) -> None: """Check all cameras for any state changes for registered providers.""" - _async_check_conflicting_legacy_provider(hass) - component = hass.data[DATA_COMPONENT] await asyncio.gather( *(camera.async_refresh_providers() for camera in component.entities) @@ -380,21 +360,6 @@ async def async_get_supported_provider( return None -async def async_get_supported_legacy_provider( - hass: HomeAssistant, camera: Camera -) -> CameraWebRTCLegacyProvider | None: - """Return the first supported provider for the camera.""" - providers = hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS) - if not providers or not (stream_source := await camera.stream_source()): - return None - - for provider in providers.values(): - if await provider.async_is_supported(stream_source): - return provider - - return None - - @callback def async_register_ice_servers( hass: HomeAssistant, @@ -411,94 +376,3 @@ def async_register_ice_servers( servers.append(get_ice_server_fn) return remove - - -# The following code is legacy code that was introduced with rtsp_to_webrtc and will be deprecated/removed in the future. -# Left it so custom integrations can still use it. - -_RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"} - -# An RtspToWebRtcProvider accepts these inputs: -# stream_source: The RTSP url -# offer_sdp: The WebRTC SDP offer -# stream_id: A unique id for the stream, used to update an existing source -# The output is the SDP answer, or None if the source or offer is not eligible. -# The Callable may throw HomeAssistantError on failure. -type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]] - - -class _CameraRtspToWebRTCProvider(CameraWebRTCLegacyProvider): - def __init__(self, fn: RtspToWebRtcProviderType) -> None: - """Initialize the RTSP to WebRTC provider.""" - self._fn = fn - - async def async_is_supported(self, stream_source: str) -> bool: - """Return if this provider is supports the Camera as source.""" - return any(stream_source.startswith(prefix) for prefix in _RTSP_PREFIXES) - - async def async_handle_web_rtc_offer( - self, camera: Camera, offer_sdp: str - ) -> str | None: - """Handle the WebRTC offer and return an answer.""" - if not (stream_source := await camera.stream_source()): - return None - - return await self._fn(stream_source, offer_sdp, camera.entity_id) - - -@deprecated_function("async_register_webrtc_provider", breaks_in_ha_version="2025.6") -def async_register_rtsp_to_web_rtc_provider( - hass: HomeAssistant, - domain: str, - provider: RtspToWebRtcProviderType, -) -> Callable[[], None]: - """Register an RTSP to WebRTC provider. - - The first provider to satisfy the offer will be used. - """ - if DOMAIN not in hass.data: - raise ValueError("Unexpected state, camera not loaded") - - legacy_providers = hass.data.setdefault(DATA_WEBRTC_LEGACY_PROVIDERS, {}) - - if domain in legacy_providers: - raise ValueError("Provider already registered") - - provider_instance = _CameraRtspToWebRTCProvider(provider) - - @callback - def remove_provider() -> None: - legacy_providers.pop(domain) - hass.async_create_task(_async_refresh_providers(hass)) - - legacy_providers[domain] = provider_instance - hass.async_create_task(_async_refresh_providers(hass)) - - return remove_provider - - -@callback -def _async_check_conflicting_legacy_provider(hass: HomeAssistant) -> None: - """Check if a legacy provider is registered together with the builtin provider.""" - builtin_provider_domain = "go2rtc" - if ( - (legacy_providers := hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS)) - and (providers := hass.data.get(DATA_WEBRTC_PROVIDERS)) - and any(provider.domain == builtin_provider_domain for provider in providers) - ): - for domain in legacy_providers: - ir.async_create_issue( - hass, - DOMAIN, - f"legacy_webrtc_provider_{domain}", - is_fixable=False, - is_persistent=False, - issue_domain=domain, - learn_more_url="https://www.home-assistant.io/integrations/go2rtc/", - severity=ir.IssueSeverity.WARNING, - translation_key="legacy_webrtc_provider", - translation_placeholders={ - "legacy_integration": domain, - "builtin_integration": builtin_provider_domain, - }, - ) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 8ff078dfafd..e17360127b9 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -60,7 +60,7 @@ from .const import ( ADDED_CAST_DEVICES_KEY, CAST_MULTIZONE_MANAGER_KEY, CONF_IGNORE_CEC, - DOMAIN as CAST_DOMAIN, + DOMAIN, SIGNAL_CAST_DISCOVERED, SIGNAL_CAST_REMOVED, SIGNAL_HASS_CAST_SHOW_VIEW, @@ -315,7 +315,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): self._cast_view_remove_handler: CALLBACK_TYPE | None = None self._attr_unique_id = str(cast_info.uuid) self._attr_device_info = DeviceInfo( - identifiers={(CAST_DOMAIN, str(cast_info.uuid).replace("-", ""))}, + identifiers={(DOMAIN, str(cast_info.uuid).replace("-", ""))}, manufacturer=str(cast_info.cast_info.manufacturer), model=cast_info.cast_info.model_name, name=str(cast_info.friendly_name), @@ -591,7 +591,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): """Generate root node.""" children = [] # Add media browsers - for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values(): + for platform in self.hass.data[DOMAIN]["cast_platform"].values(): children.extend( await platform.async_get_media_browser_root_object( self.hass, self._chromecast.cast_type @@ -650,7 +650,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): platform: CastProtocol assert media_content_type is not None - for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values(): + for platform in self.hass.data[DOMAIN]["cast_platform"].values(): browse_media = await platform.async_browse_media( self.hass, media_content_type, @@ -680,7 +680,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) # Handle media supported by a known cast app - if media_type == CAST_DOMAIN: + if media_type == DOMAIN: try: app_data = json.loads(media_id) if metadata := extra.get("metadata"): @@ -712,7 +712,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): return # Try the cast platforms - for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values(): + for platform in self.hass.data[DOMAIN]["cast_platform"].values(): result = await platform.async_play_media( self.hass, self.entity_id, chromecast, media_type, media_id ) diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 8c7c7c0cff0..aa52d21e05f 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -10,12 +10,12 @@ "known_hosts": "Add known host" }, "data_description": { - "known_hosts": "Hostnames or IP-addresses of cast devices, use if mDNS discovery is not working" + "known_hosts": "Hostnames or IP addresses of cast devices, use if mDNS discovery is not working" } } }, "error": { - "invalid_known_hosts": "Known hosts must be a comma separated list of hosts." + "invalid_known_hosts": "Known hosts must be a comma-separated list of hosts." } }, "options": { diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 287a2397121..03acaa08294 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -18,23 +18,20 @@ from homeassistant.const import ( SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, - STATE_ON, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue +from homeassistant.loader import async_suggest_report_issue from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( # noqa: F401 - ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -77,7 +74,6 @@ from .const import ( # noqa: F401 PRESET_HOME, PRESET_NONE, PRESET_SLEEP, - SERVICE_SET_AUX_HEAT, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, @@ -168,12 +164,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_handle_set_preset_mode_service", [ClimateEntityFeature.PRESET_MODE], ) - component.async_register_entity_service( - SERVICE_SET_AUX_HEAT, - {vol.Required(ATTR_AUX_HEAT): cv.boolean}, - async_service_aux_heat, - [ClimateEntityFeature.AUX_HEAT], - ) component.async_register_entity_service( SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA, @@ -239,7 +229,6 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "target_temperature_low", "preset_mode", "preset_modes", - "is_aux_heat", "fan_mode", "fan_modes", "swing_mode", @@ -279,7 +268,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_hvac_action: HVACAction | None = None _attr_hvac_mode: HVACMode | None _attr_hvac_modes: list[HVACMode] - _attr_is_aux_heat: bool | None _attr_max_humidity: float = DEFAULT_MAX_HUMIDITY _attr_max_temp: float _attr_min_humidity: float = DEFAULT_MIN_HUMIDITY @@ -299,52 +287,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_target_temperature: float | None = None _attr_temperature_unit: str - __climate_reported_legacy_aux = False - - def _report_legacy_aux(self) -> None: - """Log warning and create an issue if the entity implements legacy auxiliary heater.""" - - report_issue = async_suggest_report_issue( - self.hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - _LOGGER.warning( - ( - "%s::%s implements the `is_aux_heat` property or uses the auxiliary " - "heater methods in a subclass of ClimateEntity which is " - "deprecated and will be unsupported from Home Assistant 2025.4." - " Please %s" - ), - self.platform.platform_name, - self.__class__.__name__, - report_issue, - ) - - translation_placeholders = {"platform": self.platform.platform_name} - translation_key = "deprecated_climate_aux_no_url" - issue_tracker = async_get_issue_tracker( - self.hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - if issue_tracker: - translation_placeholders["issue_tracker"] = issue_tracker - translation_key = "deprecated_climate_aux_url_custom" - ir.async_create_issue( - self.hass, - DOMAIN, - f"deprecated_climate_aux_{self.platform.platform_name}", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - is_persistent=False, - issue_domain=self.platform.platform_name, - severity=ir.IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - self.__climate_reported_legacy_aux = True - @final @property def state(self) -> str | None: @@ -453,14 +395,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features: data[ATTR_SWING_HORIZONTAL_MODE] = self.swing_horizontal_mode - if ClimateEntityFeature.AUX_HEAT in supported_features: - data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF - if ( - self.__climate_reported_legacy_aux is False - and "custom_components" in type(self).__module__ - ): - self._report_legacy_aux() - return data @cached_property @@ -540,14 +474,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ return self._attr_preset_modes - @cached_property - def is_aux_heat(self) -> bool | None: - """Return true if aux heater. - - Requires ClimateEntityFeature.AUX_HEAT. - """ - return self._attr_is_aux_heat - @cached_property def fan_mode(self) -> str | None: """Return the fan setting. @@ -732,22 +658,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Set new preset mode.""" await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) - def turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - raise NotImplementedError - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - await self.hass.async_add_executor_job(self.turn_aux_heat_on) - - def turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - raise NotImplementedError - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - await self.hass.async_add_executor_job(self.turn_aux_heat_off) - def turn_on(self) -> None: """Turn the entity on.""" raise NotImplementedError @@ -845,16 +755,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return self._attr_max_humidity -async def async_service_aux_heat( - entity: ClimateEntity, service_call: ServiceCall -) -> None: - """Handle aux heat service.""" - if service_call.data[ATTR_AUX_HEAT]: - await entity.async_turn_aux_heat_on() - else: - await entity.async_turn_aux_heat_off() - - async def async_service_humidity_set( entity: ClimateEntity, service_call: ServiceCall ) -> None: diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index ecc0066cd93..7db80281635 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -96,7 +96,6 @@ class HVACAction(StrEnum): CURRENT_HVAC_ACTIONS = [cls.value for cls in HVACAction] -ATTR_AUX_HEAT = "aux_heat" ATTR_CURRENT_HUMIDITY = "current_humidity" ATTR_CURRENT_TEMPERATURE = "current_temperature" ATTR_FAN_MODES = "fan_modes" @@ -128,7 +127,6 @@ DOMAIN = "climate" INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" -SERVICE_SET_AUX_HEAT = "set_aux_heat" SERVICE_SET_FAN_MODE = "set_fan_mode" SERVICE_SET_PRESET_MODE = "set_preset_mode" SERVICE_SET_HUMIDITY = "set_humidity" @@ -147,7 +145,6 @@ class ClimateEntityFeature(IntFlag): FAN_MODE = 8 PRESET_MODE = 16 SWING_MODE = 32 - AUX_HEAT = 64 TURN_OFF = 128 TURN_ON = 256 SWING_HORIZONTAL_MODE = 512 diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 68421bf2386..fb5ba4f1796 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -1,17 +1,5 @@ # Describes the format for available climate services -set_aux_heat: - target: - entity: - domain: climate - supported_features: - - climate.ClimateEntityFeature.AUX_HEAT - fields: - aux_heat: - required: true - selector: - boolean: - set_preset_mode: target: entity: diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py index 2b7e2c5d8b1..7bc42d5dbd5 100644 --- a/homeassistant/components/climate/significant_change.py +++ b/homeassistant/components/climate/significant_change.py @@ -12,7 +12,6 @@ from homeassistant.helpers.significant_change import ( ) from . import ( - ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -27,7 +26,6 @@ from . import ( ) SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -67,7 +65,6 @@ def async_check_significant_change( for attr_name in changed_attrs: if attr_name in [ - ATTR_AUX_HEAT, ATTR_FAN_MODE, ATTR_HVAC_ACTION, ATTR_PRESET_MODE, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 250b2a67efe..bd6ed083650 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -36,9 +36,6 @@ "fan_only": "Fan only" }, "state_attributes": { - "aux_heat": { - "name": "Aux heat" - }, "current_humidity": { "name": "Current humidity" }, @@ -149,16 +146,6 @@ } }, "services": { - "set_aux_heat": { - "name": "Turn on/off auxiliary heater", - "description": "Turns auxiliary heater on/off.", - "fields": { - "aux_heat": { - "name": "Auxiliary heating", - "description": "New value of auxiliary heater." - } - } - }, "set_preset_mode": { "name": "Set preset mode", "description": "Sets preset mode.", @@ -267,16 +254,6 @@ } } }, - "issues": { - "deprecated_climate_aux_url_custom": { - "title": "The {platform} custom integration is using deprecated climate auxiliary heater", - "description": "The custom integration `{platform}` implements the `is_aux_heat` property or uses the auxiliary heater methods in a subclass of ClimateEntity.\n\nPlease create a bug report at {issue_tracker}.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." - }, - "deprecated_climate_aux_no_url": { - "title": "[%key:component::climate::issues::deprecated_climate_aux_url_custom::title%]", - "description": "The custom integration `{platform}` implements the `is_aux_heat` property or uses the auxiliary heater methods in a subclass of ClimateEntity.\n\nPlease report it to the author of the {platform} integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." - } - }, "exceptions": { "not_valid_preset_mode": { "message": "Preset mode {mode} is not valid. Valid preset modes are: {modes}." diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 97210b4197c..2c7c6f80d49 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -61,7 +61,6 @@ from .const import ( CONF_RELAYER_SERVER, CONF_REMOTESTATE_SERVER, CONF_SERVICEHANDLERS_SERVER, - CONF_THINGTALK_SERVER, CONF_USER_POOL_ID, DATA_CLOUD, DATA_CLOUD_LOG_HANDLER, @@ -134,7 +133,6 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, - vol.Optional(CONF_THINGTALK_SERVER): str, vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, } ) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 5b77a02384d..5bd40eb5b83 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -43,7 +43,7 @@ from homeassistant.util.dt import utcnow from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, - DOMAIN as CLOUD_DOMAIN, + DOMAIN, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_SHOULD_EXPOSE, @@ -55,7 +55,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" +CLOUD_ALEXA = f"{DOMAIN}.{ALEXA_DOMAIN}" # Time to wait when entity preferences have changed before syncing it to # the cloud. diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index ea3d992e8f7..a857185f07f 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -26,7 +26,11 @@ from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.util.aiohttp import MockRequest, serialize_response from . import alexa_config, google_config @@ -36,8 +40,10 @@ from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) VALID_REPAIR_TRANSLATION_KEYS = { + "no_subscription", "warn_bad_custom_domain_configuration", "reset_bad_custom_domain_configuration", + "subscription_expired", } @@ -399,7 +405,12 @@ class CloudClient(Interface): ) -> None: """Create a repair issue.""" if translation_key not in VALID_REPAIR_TRANSLATION_KEYS: - raise ValueError(f"Invalid translation key {translation_key}") + _LOGGER.error( + "Invalid translation key %s for repair issue %s", + translation_key, + identifier, + ) + return async_create_issue( hass=self._hass, domain=DOMAIN, @@ -409,3 +420,7 @@ class CloudClient(Interface): severity=IssueSeverity(severity), is_fixable=False, ) + + async def async_delete_repair_issue(self, identifier: str) -> None: + """Delete a repair issue.""" + async_delete_issue(hass=self._hass, domain=DOMAIN, issue_id=identifier) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9a977d2a5b9..1f154832ef9 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -81,7 +81,6 @@ CONF_ACME_SERVER = "acme_server" CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_RELAYER_SERVER = "relayer_server" CONF_REMOTESTATE_SERVER = "remotestate_server" -CONF_THINGTALK_SERVER = "thingtalk_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" MODE_DEV = "development" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 43dd5279d35..2b6f45ec474 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -41,7 +41,7 @@ from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, DEFAULT_DISABLE_2FA, - DOMAIN as CLOUD_DOMAIN, + DOMAIN, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) @@ -52,7 +52,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" +CLOUD_GOOGLE = f"{DOMAIN}.{GOOGLE_DOMAIN}" SUPPORTED_DOMAINS = { diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 7c7cb925e4f..998f3fcd5bc 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -16,7 +16,7 @@ from typing import Any, Concatenate, cast import aiohttp from aiohttp import web import attr -from hass_nabucasa import AlreadyConnectedError, Cloud, auth, thingtalk +from hass_nabucasa import AlreadyConnectedError, Cloud, auth from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice_data import TTS_VOICES import voluptuous as vol @@ -104,7 +104,6 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, alexa_list) websocket_api.async_register_command(hass, alexa_sync) - websocket_api.async_register_command(hass, thingtalk_convert) websocket_api.async_register_command(hass, tts_info) hass.http.register_view(GoogleActionsSyncView) @@ -998,25 +997,6 @@ async def alexa_sync( ) -@websocket_api.websocket_command({"type": "cloud/thingtalk/convert", "query": str}) -@websocket_api.async_response -async def thingtalk_convert( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Convert a query.""" - cloud = hass.data[DATA_CLOUD] - - async with asyncio.timeout(10): - try: - connection.send_result( - msg["id"], await thingtalk.async_convert(cloud, msg["query"]) - ) - except thingtalk.ThingTalkConversionError as err: - connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) - - @websocket_api.websocket_command({"type": "cloud/tts/info"}) def tts_info( hass: HomeAssistant, diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 30e3925a591..faee244a074 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.96.0"], + "requirements": ["hass-nabucasa==0.101.0"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 6380ee9c312..e7d219ff69e 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -62,6 +62,10 @@ } } }, + "no_subscription": { + "title": "No subscription detected", + "description": "You do not have a Home Assistant Cloud subscription. Subscribe at {account_url}." + }, "warn_bad_custom_domain_configuration": { "title": "Detected wrong custom domain configuration", "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. Please check the DNS configuration of your domain and make sure it points to the correct CNAME." @@ -69,6 +73,10 @@ "reset_bad_custom_domain_configuration": { "title": "Custom domain ignored", "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. This domain has now been ignored and will not be used for Home Assistant Cloud. If you want to use this domain, please fix the DNS configuration and restart Home Assistant. If you do not need this anymore, you can remove it from the account page." + }, + "subscription_expired": { + "title": "Subscription has expired", + "description": "Your Home Assistant Cloud subscription has expired. Resubscribe at {account_url}." } }, "services": { diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index e7890cddff8..84761a89722 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -9,6 +9,7 @@ from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -17,12 +18,12 @@ from homeassistant.components.climate import ( ) 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 .const import DOMAIN +from .const import PRESET_MODE_AUTO, PRESET_MODE_AUTO_TARGET_TEMP, PRESET_MODE_MANUAL from .coordinator import ComelitConfigEntry, ComelitSerialBridge from .entity import ComelitBridgeBaseEntity +from .utils import bridge_api_call, cleanup_stale_entity, load_api_data # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -40,11 +41,13 @@ class ClimaComelitMode(StrEnum): class ClimaComelitCommand(StrEnum): """Serial Bridge clima commands.""" + AUTO = "auto" + MANUAL = "man" OFF = "off" ON = "on" - MANUAL = "man" SET = "set" - AUTO = "auto" + SNOW = "lower" + SUN = "upper" class ClimaComelitApiStatus(TypedDict): @@ -66,11 +69,15 @@ API_STATUS: dict[str, ClimaComelitApiStatus] = { ), } -MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = { +HVACMODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = { HVACMode.OFF: ClimaComelitCommand.OFF, - HVACMode.AUTO: ClimaComelitCommand.AUTO, - HVACMode.COOL: ClimaComelitCommand.MANUAL, - HVACMode.HEAT: ClimaComelitCommand.MANUAL, + HVACMode.COOL: ClimaComelitCommand.SNOW, + HVACMode.HEAT: ClimaComelitCommand.SUN, +} + +PRESET_MODE_TO_ACTION: dict[str, ClimaComelitCommand] = { + PRESET_MODE_MANUAL: ClimaComelitCommand.MANUAL, + PRESET_MODE_AUTO: ClimaComelitCommand.AUTO, } @@ -83,26 +90,42 @@ async def async_setup_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - async_add_entities( - ComelitClimateEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[CLIMATE].values() - ) + entities: list[ClimateEntity] = [] + for device in coordinator.data[CLIMATE].values(): + values = load_api_data(device, CLIMATE_DOMAIN) + if values[0] == 0 and values[4] == 0: + # No climate data, device is only a humidifier/dehumidifier + + await cleanup_stale_entity( + hass, config_entry, f"{config_entry.entry_id}-{device.index}", device + ) + + continue + + entities.append( + ComelitClimateEntity(coordinator, device, config_entry.entry_id) + ) + + async_add_entities(entities) class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity): """Climate device.""" - _attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] + _attr_hvac_modes = [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] + _attr_preset_modes = [PRESET_MODE_AUTO, PRESET_MODE_MANUAL] _attr_max_temp = 30 _attr_min_temp = 5 _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.PRESET_MODE ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_name = None + _attr_translation_key = "thermostat" def __init__( self, @@ -117,20 +140,14 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity): def _update_attributes(self) -> None: """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] - if not isinstance(device.val, list): - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="invalid_clima_data" - ) - - # CLIMATE has a 2 item tuple: - # - first for Clima - # - second for Humidifier - values = device.val[0] + values = load_api_data(device, CLIMATE_DOMAIN) _active = values[1] _mode = values[2] # Values from API: "O", "L", "U" _automatic = values[3] == ClimaComelitMode.AUTO + self._attr_preset_mode = PRESET_MODE_AUTO if _automatic else PRESET_MODE_MANUAL + self._attr_current_temperature = values[0] / 10 self._attr_hvac_action = None @@ -140,10 +157,6 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity): self._attr_hvac_action = API_STATUS[_mode]["hvac_action"] self._attr_hvac_mode = None - if _mode == ClimaComelitMode.OFF: - self._attr_hvac_mode = HVACMode.OFF - if _automatic: - self._attr_hvac_mode = HVACMode.AUTO if _mode in API_STATUS: self._attr_hvac_mode = API_STATUS[_mode]["hvac_mode"] @@ -155,31 +168,48 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity): self._update_attributes() super()._handle_coordinator_update() + @bridge_api_call async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ( - target_temp := kwargs.get(ATTR_TEMPERATURE) - ) is None or self.hvac_mode == HVACMode.OFF: + (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None + or self.hvac_mode == HVACMode.OFF + or self._attr_preset_mode == PRESET_MODE_AUTO + ): return - await self.coordinator.api.set_clima_status( - self._device.index, ClimaComelitCommand.MANUAL - ) await self.coordinator.api.set_clima_status( self._device.index, ClimaComelitCommand.SET, target_temp ) self._attr_target_temperature = target_temp self.async_write_ha_state() + @bridge_api_call async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" - if hvac_mode != HVACMode.OFF: + if self._attr_hvac_mode == HVACMode.OFF: await self.coordinator.api.set_clima_status( self._device.index, ClimaComelitCommand.ON ) await self.coordinator.api.set_clima_status( - self._device.index, MODE_TO_ACTION[hvac_mode] + self._device.index, HVACMODE_TO_ACTION[hvac_mode] ) self._attr_hvac_mode = hvac_mode self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + + if self._attr_hvac_mode == HVACMode.OFF: + return + + await self.coordinator.api.set_clima_status( + self._device.index, PRESET_MODE_TO_ACTION[preset_mode] + ) + self._attr_preset_mode = preset_mode + + if preset_mode == PRESET_MODE_AUTO: + self._attr_target_temperature = PRESET_MODE_AUTO_TARGET_TEMP + + self.async_write_ha_state() diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 10180236f79..5b09b582c66 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -28,20 +28,22 @@ DEFAULT_HOST = "192.168.1.252" DEFAULT_PIN = 111111 -def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: - """Return user form schema.""" - user_input = user_input or {} - return vol.Schema( - { - vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, - vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), - } - ) - - +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), + } +) STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int}) +STEP_RECONFIGURE = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + } +) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: @@ -87,13 +89,11 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" if user_input is None: - return self.async_show_form( - step_id="user", data_schema=user_form_schema(user_input) - ) + return self.async_show_form(step_id="user", data_schema=USER_SCHEMA) self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - errors = {} + errors: dict[str, str] = {} try: info = await validate_input(self.hass, user_input) @@ -108,21 +108,21 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( - step_id="user", data_schema=user_form_schema(user_input), errors=errors + step_id="user", data_schema=USER_SCHEMA, errors=errors ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth flow.""" - self.context["title_placeholders"] = {"host": entry_data[CONF_HOST]} + self.context["title_placeholders"] = {CONF_HOST: entry_data[CONF_HOST]} return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reauth confirm.""" - errors = {} + errors: dict[str, str] = {} reauth_entry = self._get_reauth_entry() entry_data = reauth_entry.data @@ -163,6 +163,42 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the device.""" + reconfigure_entry = self._get_reconfigure_entry() + if not user_input: + return self.async_show_form( + step_id="reconfigure", data_schema=STEP_RECONFIGURE + ) + + updated_host = user_input[CONF_HOST] + + self._async_abort_entries_match({CONF_HOST: updated_host}) + + errors: dict[str, str] = {} + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates={CONF_HOST: updated_host} + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_RECONFIGURE, + errors=errors, + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index f52f33fd6da..4baaf0ee426 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -11,3 +11,8 @@ DEFAULT_PORT = 80 DEVICE_TYPE_LIST = [BRIDGE, VEDO] SCAN_INTERVAL = 5 + +PRESET_MODE_AUTO = "automatic" +PRESET_MODE_MANUAL = "manual" + +PRESET_MODE_AUTO_TARGET_TEMP = 20 diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index d430952fabf..691ebaec638 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -7,13 +7,14 @@ from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON -from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState +from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge from .entity import ComelitBridgeBaseEntity +from .utils import bridge_api_call # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -68,16 +69,10 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): def is_closed(self) -> bool | None: """Return if the cover is closed.""" - if self._last_state in [None, "unknown"]: - return None - - if self.device_status != STATE_COVER.index("stopped"): - return False - if self._last_action: return self._last_action == STATE_COVER.index("closing") - return self._last_state == CoverState.CLOSED + return None @property def is_closing(self) -> bool: @@ -89,6 +84,7 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): """Return if the cover is opening.""" return self._current_action("opening") + @bridge_api_call async def _cover_set_state(self, action: int, state: int) -> None: """Set desired cover state.""" self._last_state = self.state diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index 816d5c6bb38..4a7361022ce 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -9,6 +9,7 @@ from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE from homeassistant.components.humidifier import ( + DOMAIN as HUMIDIFIER_DOMAIN, MODE_AUTO, MODE_NORMAL, HumidifierAction, @@ -17,12 +18,13 @@ from homeassistant.components.humidifier import ( HumidifierEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge from .entity import ComelitBridgeBaseEntity +from .utils import bridge_api_call, cleanup_stale_entity, load_api_data # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -66,6 +68,23 @@ async def async_setup_entry( entities: list[ComelitHumidifierEntity] = [] for device in coordinator.data[CLIMATE].values(): + values = load_api_data(device, HUMIDIFIER_DOMAIN) + if values[0] == 0 and values[4] == 0: + # No humidity data, device is only a climate + + for device_class in ( + HumidifierDeviceClass.HUMIDIFIER, + HumidifierDeviceClass.DEHUMIDIFIER, + ): + await cleanup_stale_entity( + hass, + config_entry, + f"{config_entry.entry_id}-{device.index}-{device_class}", + device, + ) + + continue + entities.append( ComelitHumidifierEntity( coordinator, @@ -123,15 +142,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity): def _update_attributes(self) -> None: """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] - if not isinstance(device.val, list): - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="invalid_clima_data" - ) - - # CLIMATE has a 2 item tuple: - # - first for Clima - # - second for Humidifier - values = device.val[1] + values = load_api_data(device, HUMIDIFIER_DOMAIN) _active = values[1] _mode = values[2] # Values from API: "O", "L", "U" @@ -154,6 +165,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity): self._update_attributes() super()._handle_coordinator_update() + @bridge_api_call async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if not self._attr_is_on: @@ -171,6 +183,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity): self._attr_target_humidity = humidity self.async_write_ha_state() + @bridge_api_call async def async_set_mode(self, mode: str) -> None: """Set humidifier mode.""" await self.coordinator.api.set_humidity_status( @@ -179,6 +192,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity): self._attr_mode = mode self.async_write_ha_state() + @bridge_api_call async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" await self.coordinator.api.set_humidity_status( @@ -187,6 +201,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity): self._attr_is_on = True self.async_write_ha_state() + @bridge_api_call async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" await self.coordinator.api.set_humidity_status( diff --git a/homeassistant/components/comelit/icons.json b/homeassistant/components/comelit/icons.json index 6c42d20de65..6ac83cfc8e0 100644 --- a/homeassistant/components/comelit/icons.json +++ b/homeassistant/components/comelit/icons.json @@ -4,6 +4,18 @@ "zone_status": { "default": "mdi:shield-check" } + }, + "climate": { + "thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "automatic": "mdi:refresh-auto", + "manual": "mdi:alpha-m" + } + } + } + } } } } diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 27d9a8d57dd..c04b88c7819 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ComelitConfigEntry, ComelitSerialBridge from .entity import ComelitBridgeBaseEntity +from .utils import bridge_api_call # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -39,6 +40,7 @@ class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): _attr_name = None _attr_supported_color_modes = {ColorMode.ONOFF} + @bridge_api_call 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 bea84c6b805..44101f0fd06 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["aiocomelit==0.12.3"] } diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml index b6d6cbc1046..4fbbd79d60d 100644 --- a/homeassistant/components/comelit/quality_scale.yaml +++ b/homeassistant/components/comelit/quality_scale.yaml @@ -26,9 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: wrap api calls in try block + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -55,26 +53,22 @@ rules: 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-supported-devices: done + docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done dynamic-devices: status: todo comment: missing implementation entity-category: - status: todo - comment: PR in progress + status: exempt + comment: no config or diagnostic entities 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 + reconfiguration-flow: done repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 8f2ae1433e5..d63d22f307a 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -23,11 +23,24 @@ "pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]", "type": "The type of your Comelit device." } + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "host": "[%key:component::comelit::config::step::user::data_description::host%]", + "port": "[%key:component::comelit::config::step::user::data_description::port%]", + "pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "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%]" @@ -61,6 +74,18 @@ "dehumidifier": { "name": "Dehumidifier" } + }, + "climate": { + "thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "automatic": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]" + } + } + } + } } }, "exceptions": { @@ -76,6 +101,9 @@ "cannot_authenticate": { "message": "Error authenticating" }, + "cannot_retrieve_data": { + "message": "Error retrieving data: {error}" + }, "update_failed": { "message": "Failed to update data: {error}" } diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 658f37f70af..1896071596f 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ComelitConfigEntry, ComelitSerialBridge from .entity import ComelitBridgeBaseEntity +from .utils import bridge_api_call # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -56,6 +57,7 @@ class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): if device.type == OTHER: self._attr_device_class = SwitchDeviceClass.OUTLET + @bridge_api_call async def _switch_set_state(self, state: int) -> None: """Set desired switch state.""" await self.coordinator.api.set_device_status( diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py index fe05e2412b0..d0f0fbbee3f 100644 --- a/homeassistant/components/comelit/utils.py +++ b/homeassistant/components/comelit/utils.py @@ -1,9 +1,25 @@ """Utils for Comelit.""" +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData from aiohttp import ClientSession, CookieJar +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + aiohttp_client, + device_registry as dr, + entity_registry as er, +) + +from .const import _LOGGER, DOMAIN +from .entity import ComelitBridgeBaseEntity async def async_client_session(hass: HomeAssistant) -> ClientSession: @@ -11,3 +27,89 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession: return aiohttp_client.async_create_clientsession( hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) ) + + +def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]: + """Load data from the API.""" + # This function is called when the data is loaded from the API + if not isinstance(device.val, list): + raise HomeAssistantError( + translation_domain=domain, translation_key="invalid_clima_data" + ) + # CLIMATE has a 2 item tuple: + # - first for Clima + # - second for Humidifier + return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1] + + +async def cleanup_stale_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + entry_unique_id: str, + device: ComelitSerialBridgeObject, +) -> None: + """Cleanup stale entity.""" + entity_reg: er.EntityRegistry = er.async_get(hass) + + identifiers: list[str] = [] + + for entry in er.async_entries_for_config_entry(entity_reg, config_entry.entry_id): + if entry.unique_id == entry_unique_id: + entry_name = entry.name or entry.original_name + _LOGGER.info("Removing entity: %s [%s]", entry.entity_id, entry_name) + entity_reg.async_remove(entry.entity_id) + identifiers.append(f"{config_entry.entry_id}-{device.type}-{device.index}") + + if len(identifiers) > 0: + _async_remove_state_config_entry_from_devices(hass, identifiers, config_entry) + + +def _async_remove_state_config_entry_from_devices( + hass: HomeAssistant, identifiers: list[str], config_entry: ConfigEntry +) -> None: + """Remove config entry from device.""" + + device_registry = dr.async_get(hass) + for identifier in identifiers: + device = device_registry.async_get_device(identifiers={(DOMAIN, identifier)}) + if device: + _LOGGER.info( + "Removing config entry %s from device %s", + config_entry.title, + device.name, + ) + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=config_entry.entry_id, + ) + + +def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch Bridge API 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 CannotConnect as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotRetrieveData as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotAuthenticate: + self.coordinator.last_update_success = False + self.coordinator.config_entry.async_start_reauth(self.hass) + + return cmd_wrapper diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index ec1b51a47c7..50bfbe651ef 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -9,10 +9,12 @@ from typing import Any from homeassistant.components.notify import BaseNotificationService from homeassistant.const import CONF_COMMAND from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.process import kill_subprocess -from .const import CONF_COMMAND_TIMEOUT +from .const import CONF_COMMAND_TIMEOUT, LOGGER _LOGGER = logging.getLogger(__name__) @@ -43,8 +45,31 @@ class CommandLineNotificationService(BaseNotificationService): def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a command line.""" + command = self.command + if " " not in command: + prog = command + args = None + args_compiled = None + else: + prog, args = command.split(" ", 1) + args_compiled = Template(args, self.hass) + + rendered_args = None + if args_compiled: + args_to_render = {"arguments": args} + try: + rendered_args = args_compiled.async_render(args_to_render) + except TemplateError as ex: + LOGGER.exception("Error rendering command template: %s", ex) + return + + if rendered_args != args: + command = f"{prog} {rendered_args}" + + LOGGER.debug("Running command: %s, with message: %s", command, message) + with subprocess.Popen( # noqa: S602 # shell by design - self.command, + command, universal_newlines=True, stdin=subprocess.PIPE, close_fds=False, # required for posix_spawn @@ -56,10 +81,10 @@ class CommandLineNotificationService(BaseNotificationService): _LOGGER.error( "Command failed (with return code %s): %s", proc.returncode, - self.command, + command, ) except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", self.command) + _LOGGER.error("Timeout for command: %s", command) kill_subprocess(proc) except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", self.command) + _LOGGER.error("Error trying to exec command: %s", command) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 6e2d4a5da49..d20d4de881f 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -165,9 +165,7 @@ class ConfigManagerFlowIndexView( """Not implemented.""" raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission="add") @RequestDataValidator( vol.Schema( { @@ -218,16 +216,12 @@ class ConfigManagerFlowResourceView( url = "/api/config/config_entries/flow/{flow_id}" name = "api:config:config_entries:flow:resource" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission="add") async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission="add") async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) @@ -262,9 +256,7 @@ class OptionManagerFlowIndexView( url = "/api/config/config_entries/options/flow" name = "api:config:config_entries:option:flow" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def post(self, request: web.Request) -> web.Response: """Handle a POST request. @@ -281,16 +273,12 @@ class OptionManagerFlowResourceView( url = "/api/config/config_entries/options/flow/{flow_id}" name = "api:config:config_entries:options:flow:resource" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) @@ -304,9 +292,7 @@ class SubentryManagerFlowIndexView( url = "/api/config/config_entries/subentries/flow" name = "api:config:config_entries:subentries:flow" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) @RequestDataValidator( vol.Schema( { @@ -341,16 +327,12 @@ class SubentryManagerFlowResourceView( url = "/api/config/config_entries/subentries/flow/{flow_id}" name = "api:config:config_entries:subentries:flow:resource" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index b987f249a33..d619b585230 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any import voluptuous as vol @@ -10,18 +11,23 @@ from homeassistant import config_entries from homeassistant.components import websocket_api from homeassistant.components.websocket_api import ERR_NOT_FOUND, require_admin from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.entity_component import async_get_entity_suggested_object_id from homeassistant.helpers.json import json_dumps +_LOGGER = logging.getLogger(__name__) + @callback def async_setup(hass: HomeAssistant) -> bool: """Enable the Entity Registry views.""" + websocket_api.async_register_command(hass, websocket_get_automatic_entity_ids) websocket_api.async_register_command(hass, websocket_get_entities) websocket_api.async_register_command(hass, websocket_get_entity) websocket_api.async_register_command(hass, websocket_list_entities_for_display) @@ -316,3 +322,54 @@ def websocket_remove_entity( registry.async_remove(msg["entity_id"]) connection.send_message(websocket_api.result_message(msg["id"])) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/entity_registry/get_automatic_entity_ids", + vol.Required("entity_ids"): cv.entity_ids, + } +) +@callback +def websocket_get_automatic_entity_ids( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return the automatic entity IDs for the given entity IDs. + + This is used to help user reset entity IDs which have been customized by the user. + """ + registry = er.async_get(hass) + + entity_ids = msg["entity_ids"] + automatic_entity_ids: dict[str, str | None] = {} + reserved_entity_ids: set[str] = set() + for entity_id in entity_ids: + if not (entry := registry.entities.get(entity_id)): + automatic_entity_ids[entity_id] = None + continue + try: + suggested = async_get_entity_suggested_object_id(hass, entity_id) + except HomeAssistantError as err: + # This is raised if the entity has no object. + _LOGGER.debug( + "Unable to get suggested object ID for %s, entity ID: %s (%s)", + entry.entity_id, + entity_id, + err, + ) + automatic_entity_ids[entity_id] = None + continue + suggested_entity_id = registry.async_generate_entity_id( + entry.domain, + suggested or f"{entry.platform}_{entry.unique_id}", + current_entity_id=entity_id, + reserved_entity_ids=reserved_entity_ids, + ) + automatic_entity_ids[entity_id] = suggested_entity_id + reserved_entity_ids.add(suggested_entity_id) + + connection.send_message( + websocket_api.result_message(msg["id"], automatic_entity_ids) + ) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 25aaf6df290..fff2c00641f 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -203,7 +203,11 @@ def async_get_agent_info( name = agent.name if not isinstance(name, str): name = agent.entity_id - return AgentInfo(id=agent.entity_id, name=name) + return AgentInfo( + id=agent.entity_id, + name=name, + supports_streaming=agent.supports_streaming, + ) manager = get_agent_manager(hass) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 5ff47977d88..38c0ca8db6b 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -166,6 +166,7 @@ class AgentManager: AgentInfo( id=agent_id, name=config_entry.title or config_entry.domain, + supports_streaming=False, ) ) return agents diff --git a/homeassistant/components/conversation/entity.py b/homeassistant/components/conversation/entity.py index ca4d18ab9f5..60cf24dbf96 100644 --- a/homeassistant/components/conversation/entity.py +++ b/homeassistant/components/conversation/entity.py @@ -18,8 +18,14 @@ class ConversationEntity(RestoreEntity): _attr_should_poll = False _attr_supported_features = ConversationEntityFeature(0) + _attr_supports_streaming = False __last_activity: str | None = None + @property + def supports_streaming(self) -> bool: + """Return if the entity supports streaming responses.""" + return self._attr_supports_streaming + @property @final def state(self) -> str | None: diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2955bb96833..5221e89deee 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.5.7"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.10"] } diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 7bdd13afc01..00097f5b4d3 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -16,6 +16,7 @@ class AgentInfo: id: str name: str + supports_streaming: bool @dataclass(slots=True) diff --git a/homeassistant/components/cups/__init__.py b/homeassistant/components/cups/__init__.py index 7cd5ce4ca0a..92679aec079 100644 --- a/homeassistant/components/cups/__init__.py +++ b/homeassistant/components/cups/__init__.py @@ -1 +1,4 @@ """The cups component.""" + +DOMAIN = "cups" +CONF_PRINTERS = "printers" diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index 701bad3f104..671c8c87a8c 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -14,12 +14,15 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.const import CONF_HOST, CONF_PORT, PERCENTAGE -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import CONF_PRINTERS, DOMAIN + _LOGGER = logging.getLogger(__name__) ATTR_MARKER_TYPE = "marker_type" @@ -36,7 +39,6 @@ ATTR_PRINTER_STATE_REASON = "printer_state_reason" ATTR_PRINTER_TYPE = "printer_type" ATTR_PRINTER_URI_SUPPORTED = "printer_uri_supported" -CONF_PRINTERS = "printers" CONF_IS_CUPS_SERVER = "is_cups_server" DEFAULT_HOST = "127.0.0.1" @@ -72,6 +74,21 @@ def setup_platform( printers: list[str] = config[CONF_PRINTERS] is_cups: bool = config[CONF_IS_CUPS_SERVER] + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "CUPS", + }, + ) + if is_cups: data = CupsData(host, port, None) data.update() diff --git a/homeassistant/components/danfoss_air/binary_sensor.py b/homeassistant/components/danfoss_air/binary_sensor.py index 358d6ca07ab..736604d7ea1 100644 --- a/homeassistant/components/danfoss_air/binary_sensor.py +++ b/homeassistant/components/danfoss_air/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DANFOSS_AIR_DOMAIN +from . import DOMAIN def setup_platform( @@ -22,7 +22,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the available Danfoss Air sensors etc.""" - data = hass.data[DANFOSS_AIR_DOMAIN] + data = hass.data[DOMAIN] sensors = [ [ diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index 85b4e89d434..569ba21b234 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DANFOSS_AIR_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the available Danfoss Air sensors etc.""" - data = hass.data[DANFOSS_AIR_DOMAIN] + data = hass.data[DOMAIN] sensors = [ [ diff --git a/homeassistant/components/danfoss_air/switch.py b/homeassistant/components/danfoss_air/switch.py index dc3277078b0..5e7c5728d81 100644 --- a/homeassistant/components/danfoss_air/switch.py +++ b/homeassistant/components/danfoss_air/switch.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DANFOSS_AIR_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -24,7 +24,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Danfoss Air HRV switch platform.""" - data = hass.data[DANFOSS_AIR_DOMAIN] + data = hass.data[DOMAIN] switches = [ [ diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 21211d334df..0b9f8ea55f5 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.8.13"] + "requirements": ["debugpy==1.8.14"] } diff --git a/homeassistant/components/deconz/entity.py b/homeassistant/components/deconz/entity.py index f45c35ada44..fef973d612c 100644 --- a/homeassistant/components/deconz/entity.py +++ b/homeassistant/components/deconz/entity.py @@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DOMAIN as DECONZ_DOMAIN +from .const import DOMAIN from .hub import DeconzHub from .util import serial_from_unique_id @@ -59,12 +59,12 @@ class DeconzBase[_DeviceT: _DeviceType]: return DeviceInfo( connections={(CONNECTION_ZIGBEE, self.serial)}, - identifiers={(DECONZ_DOMAIN, self.serial)}, + identifiers={(DOMAIN, self.serial)}, manufacturer=self._device.manufacturer, model=self._device.model_id, name=self._device.name, sw_version=self._device.software_version, - via_device=(DECONZ_DOMAIN, self.hub.api.config.bridge_id), + via_device=(DOMAIN, self.hub.api.config.bridge_id), ) @@ -176,9 +176,9 @@ class DeconzSceneMixin(DeconzDevice[PydeconzScene]): def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" return DeviceInfo( - identifiers={(DECONZ_DOMAIN, self._group_identifier)}, + identifiers={(DOMAIN, self._group_identifier)}, manufacturer="Dresden Elektronik", model="deCONZ group", name=self.group.name, - via_device=(DECONZ_DOMAIN, self.hub.api.config.bridge_id), + via_device=(DOMAIN, self.hub.api.config.bridge_id), ) diff --git a/homeassistant/components/deconz/hub/hub.py b/homeassistant/components/deconz/hub/hub.py index 3020d624f97..f82f1d857fd 100644 --- a/homeassistant/components/deconz/hub/hub.py +++ b/homeassistant/components/deconz/hub/hub.py @@ -17,12 +17,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send -from ..const import ( - CONF_MASTER_GATEWAY, - DOMAIN as DECONZ_DOMAIN, - HASSIO_CONFIGURATION_URL, - PLATFORMS, -) +from ..const import CONF_MASTER_GATEWAY, DOMAIN, HASSIO_CONFIGURATION_URL, PLATFORMS from .config import DeconzConfig if TYPE_CHECKING: @@ -193,7 +188,7 @@ class DeconzHub: config_entry_id=self.config_entry.entry_id, configuration_url=configuration_url, entry_type=dr.DeviceEntryType.SERVICE, - identifiers={(DECONZ_DOMAIN, self.api.config.bridge_id)}, + identifiers={(DOMAIN, self.api.config.bridge_id)}, manufacturer="Dresden Elektronik", model=self.api.config.model_id, name=self.api.config.name, diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index b61a1d39333..1eb827f85d6 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -38,7 +38,7 @@ from homeassistant.util.color import ( ) from . import DeconzConfigEntry -from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS +from .const import DOMAIN, POWER_PLUGS from .entity import DeconzDevice from .hub import DeconzHub @@ -395,11 +395,11 @@ class DeconzGroup(DeconzBaseLight[Group]): def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" return DeviceInfo( - identifiers={(DECONZ_DOMAIN, self.unique_id)}, + identifiers={(DOMAIN, self.unique_id)}, manufacturer="Dresden Elektronik", model="deCONZ group", name=self._device.name, - via_device=(DECONZ_DOMAIN, self.hub.api.config.bridge_id), + via_device=(DOMAIN, self.hub.api.config.bridge_id), ) @property diff --git a/homeassistant/components/decora/__init__.py b/homeassistant/components/decora/__init__.py index 694ff77fdb3..4ba4fb4dee0 100644 --- a/homeassistant/components/decora/__init__.py +++ b/homeassistant/components/decora/__init__.py @@ -1 +1,3 @@ """The decora component.""" + +DOMAIN = "decora" diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index a7d14b83aca..d0226a24dcc 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -21,7 +21,11 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue + +from . import DOMAIN if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -90,6 +94,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up an Decora switch.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Leviton Decora", + }, + ) + lights = [] for address, device_config in config[CONF_DEVICES].items(): device = {} diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 5cd83722742..ad7ddcba285 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -6,12 +6,16 @@ from datetime import datetime from typing import Any from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -407,3 +411,18 @@ class DemoSearchPlayer(AbstractDemoPlayer): """A Demo media player that supports searching.""" _attr_supported_features = SEARCH_PLAYER_SUPPORT + + async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia: + """Demo implementation of search media.""" + return SearchMedia( + result=[ + BrowseMedia( + title="Search result", + media_class=MediaClass.MOVIE, + media_content_type=MediaType.MOVIE, + media_content_id="search_result_id", + can_play=True, + can_expand=False, + ) + ] + ) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 328ab504bd1..c5a1b9aeb63 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==1.0.1"], + "requirements": ["denonavr==1.1.1"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 5117663f3c5..0806a8f824d 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -6,8 +6,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SOURCE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -17,6 +19,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry.entry_id, entry.options[CONF_SOURCE] ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SOURCE: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_SOURCE] + ), + source_entity_id_or_uuid=entry.options[CONF_SOURCE], + source_entity_removed=source_entity_removed, + ) + ) await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index bfdf861a019..f1b7375ae07 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -15,7 +15,7 @@ }, "data_description": { "round": "Controls the number of decimal digits in the output.", - "time_window": "If set, the sensor's value is a time weighted moving average of derivatives within this window.", + "time_window": "If set, the sensor's value is a time-weighted moving average of derivatives within this window.", "unit_prefix": "The output will be scaled according to the selected metric prefix and time unit of the derivative." } } diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index cc8c4d4d52e..071b8236086 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -8,11 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import ( - TriggerActionType, - TriggerInfo, - TriggerProtocol, -) +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import ( @@ -25,13 +21,28 @@ from .helpers import async_validate_device_automation_config TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -class DeviceAutomationTriggerProtocol(TriggerProtocol, Protocol): +class DeviceAutomationTriggerProtocol(Protocol): """Define the format of device_trigger modules. - Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config - from TriggerProtocol. + Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config. """ + TRIGGER_SCHEMA: vol.Schema + + async def async_validate_trigger_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + + async def async_attach_trigger( + self, + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, + ) -> CALLBACK_TYPE: + """Attach a trigger.""" + async def async_get_trigger_capabilities( self, hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 7f6784f2404..79d00ee50be 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -2,27 +2,13 @@ from __future__ import annotations -from asyncio import Semaphore -from dataclasses import dataclass import logging from typing import Any from devolo_plc_api import Device -from devolo_plc_api.device_api import ( - ConnectedStationInfo, - NeighborAPInfo, - UpdateFirmwareCheck, - WifiGuestAccessGet, -) -from devolo_plc_api.exceptions.device import ( - DeviceNotFound, - DevicePasswordProtected, - DeviceUnavailable, -) -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.exceptions.device import DeviceNotFound from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_IP_ADDRESS, CONF_PASSWORD, @@ -30,38 +16,34 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ( CONNECTED_PLC_DEVICES, CONNECTED_WIFI_CLIENTS, DOMAIN, - FIRMWARE_UPDATE_INTERVAL, LAST_RESTART, - LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, REGULAR_FIRMWARE, - SHORT_UPDATE_INTERVAL, SWITCH_GUEST_WIFI, SWITCH_LEDS, ) -from .coordinator import DevoloDataUpdateCoordinator +from .coordinator import ( + DevoloDataUpdateCoordinator, + DevoloFirmwareUpdateCoordinator, + DevoloHomeNetworkConfigEntry, + DevoloHomeNetworkData, + DevoloLedSettingsGetCoordinator, + DevoloLogicalNetworkCoordinator, + DevoloUptimeGetCoordinator, + DevoloWifiConnectedStationsGetCoordinator, + DevoloWifiGuestAccessGetCoordinator, + DevoloWifiNeighborAPsGetCoordinator, +) _LOGGER = logging.getLogger(__name__) -type DevoloHomeNetworkConfigEntry = ConfigEntry[DevoloHomeNetworkData] - - -@dataclass -class DevoloHomeNetworkData: - """The devolo Home Network data.""" - - device: Device - coordinators: dict[str, DevoloDataUpdateCoordinator[Any]] - async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry @@ -69,8 +51,6 @@ async def async_setup_entry( """Set up devolo Home Network from a config entry.""" zeroconf_instance = await zeroconf.async_get_async_instance(hass) async_client = get_async_client(hass) - device_registry = dr.async_get(hass) - semaphore = Semaphore(1) try: device = Device( @@ -90,177 +70,52 @@ async def async_setup_entry( entry.runtime_data = DevoloHomeNetworkData(device=device, coordinators={}) - async def async_update_firmware_available() -> UpdateFirmwareCheck: - """Fetch data from API endpoint.""" - assert device.device - update_sw_version(device_registry, device) - try: - return await device.device.async_check_firmware_available() - except DeviceUnavailable as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"error": str(err)}, - ) from err - - async def async_update_connected_plc_devices() -> LogicalNetwork: - """Fetch data from API endpoint.""" - assert device.plcnet - update_sw_version(device_registry, device) - try: - return await device.plcnet.async_get_network_overview() - except DeviceUnavailable as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"error": str(err)}, - ) from err - - async def async_update_guest_wifi_status() -> WifiGuestAccessGet: - """Fetch data from API endpoint.""" - assert device.device - update_sw_version(device_registry, device) - try: - return await device.device.async_get_wifi_guest_access() - except DeviceUnavailable as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"error": str(err)}, - ) from err - except DevicePasswordProtected as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="password_wrong" - ) from err - - async def async_update_led_status() -> bool: - """Fetch data from API endpoint.""" - assert device.device - update_sw_version(device_registry, device) - try: - return await device.device.async_get_led_setting() - except DeviceUnavailable as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"error": str(err)}, - ) from err - - async def async_update_last_restart() -> int: - """Fetch data from API endpoint.""" - assert device.device - update_sw_version(device_registry, device) - try: - return await device.device.async_uptime() - except DeviceUnavailable as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"error": str(err)}, - ) from err - except DevicePasswordProtected as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="password_wrong" - ) from err - - async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]: - """Fetch data from API endpoint.""" - assert device.device - update_sw_version(device_registry, device) - try: - return await device.device.async_get_wifi_connected_station() - except DeviceUnavailable as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"error": str(err)}, - ) from err - - async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]: - """Fetch data from API endpoint.""" - assert device.device - update_sw_version(device_registry, device) - try: - return await device.device.async_get_wifi_neighbor_access_points() - except DeviceUnavailable as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"error": str(err)}, - ) from err - async def disconnect(event: Event) -> None: """Disconnect from device.""" await device.async_disconnect() coordinators: dict[str, DevoloDataUpdateCoordinator[Any]] = {} if device.plcnet: - coordinators[CONNECTED_PLC_DEVICES] = DevoloDataUpdateCoordinator( + coordinators[CONNECTED_PLC_DEVICES] = DevoloLogicalNetworkCoordinator( hass, _LOGGER, config_entry=entry, - name=CONNECTED_PLC_DEVICES, - semaphore=semaphore, - update_method=async_update_connected_plc_devices, - update_interval=LONG_UPDATE_INTERVAL, ) if device.device and "led" in device.device.features: - coordinators[SWITCH_LEDS] = DevoloDataUpdateCoordinator( + coordinators[SWITCH_LEDS] = DevoloLedSettingsGetCoordinator( hass, _LOGGER, config_entry=entry, - name=SWITCH_LEDS, - semaphore=semaphore, - update_method=async_update_led_status, - update_interval=SHORT_UPDATE_INTERVAL, ) if device.device and "restart" in device.device.features: - coordinators[LAST_RESTART] = DevoloDataUpdateCoordinator( + coordinators[LAST_RESTART] = DevoloUptimeGetCoordinator( hass, _LOGGER, config_entry=entry, - name=LAST_RESTART, - semaphore=semaphore, - update_method=async_update_last_restart, - update_interval=SHORT_UPDATE_INTERVAL, ) if device.device and "update" in device.device.features: - coordinators[REGULAR_FIRMWARE] = DevoloDataUpdateCoordinator( + coordinators[REGULAR_FIRMWARE] = DevoloFirmwareUpdateCoordinator( hass, _LOGGER, config_entry=entry, - name=REGULAR_FIRMWARE, - semaphore=semaphore, - update_method=async_update_firmware_available, - update_interval=FIRMWARE_UPDATE_INTERVAL, ) if device.device and "wifi1" in device.device.features: - coordinators[CONNECTED_WIFI_CLIENTS] = DevoloDataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=CONNECTED_WIFI_CLIENTS, - semaphore=semaphore, - update_method=async_update_wifi_connected_station, - update_interval=SHORT_UPDATE_INTERVAL, + coordinators[CONNECTED_WIFI_CLIENTS] = ( + DevoloWifiConnectedStationsGetCoordinator( + hass, + _LOGGER, + config_entry=entry, + ) ) - coordinators[NEIGHBORING_WIFI_NETWORKS] = DevoloDataUpdateCoordinator( + coordinators[NEIGHBORING_WIFI_NETWORKS] = DevoloWifiNeighborAPsGetCoordinator( hass, _LOGGER, config_entry=entry, - name=NEIGHBORING_WIFI_NETWORKS, - semaphore=semaphore, - update_method=async_update_wifi_neighbor_access_points, - update_interval=LONG_UPDATE_INTERVAL, ) - coordinators[SWITCH_GUEST_WIFI] = DevoloDataUpdateCoordinator( + coordinators[SWITCH_GUEST_WIFI] = DevoloWifiGuestAccessGetCoordinator( hass, _LOGGER, config_entry=entry, - name=SWITCH_GUEST_WIFI, - semaphore=semaphore, - update_method=async_update_guest_wifi_status, - update_interval=SHORT_UPDATE_INTERVAL, ) for coordinator in coordinators.values(): @@ -303,16 +158,3 @@ def platforms(device: Device) -> set[Platform]: if device.device and "update" in device.device.features: supported_platforms.add(Platform.UPDATE) return supported_platforms - - -@callback -def update_sw_version(device_registry: dr.DeviceRegistry, device: Device) -> None: - """Update device registry with new firmware version.""" - if ( - device_entry := device_registry.async_get_device( - identifiers={(DOMAIN, str(device.serial_number))} - ) - ) and device_entry.sw_version != device.firmware_version: - device_registry.async_update_device( - device_id=device_entry.id, sw_version=device.firmware_version - ) diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index 2c258d758da..3b1debe42c5 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -16,9 +16,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DevoloHomeNetworkConfigEntry from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER -from .coordinator import DevoloDataUpdateCoordinator +from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry from .entity import DevoloCoordinatorEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py index fe6b1786363..53de2945d00 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -18,8 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS +from .coordinator import DevoloHomeNetworkConfigEntry from .entity import DevoloEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py index ad21289ff28..125559eefe4 100644 --- a/homeassistant/components/devolo_home_network/config_flow.py +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -17,8 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE +from .coordinator import DevoloHomeNetworkConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/devolo_home_network/coordinator.py b/homeassistant/components/devolo_home_network/coordinator.py index c0af9668279..d23aa0e935e 100644 --- a/homeassistant/components/devolo_home_network/coordinator.py +++ b/homeassistant/components/devolo_home_network/coordinator.py @@ -1,13 +1,44 @@ """Base coordinator.""" from asyncio import Semaphore -from collections.abc import Awaitable, Callable +from dataclasses import dataclass from datetime import timedelta from logging import Logger +from typing import Any + +from devolo_plc_api import Device +from devolo_plc_api.device_api import ( + ConnectedStationInfo, + NeighborAPInfo, + UpdateFirmwareCheck, + WifiGuestAccessGet, +) +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable +from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONNECTED_PLC_DEVICES, + CONNECTED_WIFI_CLIENTS, + DOMAIN, + FIRMWARE_UPDATE_INTERVAL, + LAST_RESTART, + LONG_UPDATE_INTERVAL, + NEIGHBORING_WIFI_NETWORKS, + REGULAR_FIRMWARE, + SHORT_UPDATE_INTERVAL, + SWITCH_GUEST_WIFI, + SWITCH_LEDS, +) + +SEMAPHORE = Semaphore(1) + +type DevoloHomeNetworkConfigEntry = ConfigEntry[DevoloHomeNetworkData] class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -18,11 +49,62 @@ class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): hass: HomeAssistant, logger: Logger, *, - config_entry: ConfigEntry, + config_entry: DevoloHomeNetworkConfigEntry, name: str, - semaphore: Semaphore, - update_interval: timedelta, - update_method: Callable[[], Awaitable[_DataT]], + update_interval: timedelta | None = None, + ) -> None: + """Initialize global data updater.""" + self.device = config_entry.runtime_data.device + super().__init__( + hass, + logger, + config_entry=config_entry, + name=name, + update_interval=update_interval, + ) + + async def _async_update_data(self) -> _DataT: + """Fetch the latest data from the source.""" + self.update_sw_version() + async with SEMAPHORE: + try: + return await super()._async_update_data() + except DeviceUnavailable as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err + except DevicePasswordProtected as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="password_wrong" + ) from err + + @callback + def update_sw_version(self) -> None: + """Update device registry with new firmware version, if it changed at runtime.""" + device_registry = dr.async_get(self.hass) + if ( + device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, self.device.serial_number)} + ) + ) and device_entry.sw_version != self.device.firmware_version: + device_registry.async_update_device( + device_id=device_entry.id, sw_version=self.device.firmware_version + ) + + +class DevoloFirmwareUpdateCoordinator(DevoloDataUpdateCoordinator[UpdateFirmwareCheck]): + """Class to manage fetching data from the UpdateFirmwareCheck endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + *, + config_entry: ConfigEntry, + name: str = REGULAR_FIRMWARE, + update_interval: timedelta | None = FIRMWARE_UPDATE_INTERVAL, ) -> None: """Initialize global data updater.""" super().__init__( @@ -31,11 +113,192 @@ class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): config_entry=config_entry, name=name, update_interval=update_interval, - update_method=update_method, ) - self._semaphore = semaphore + self.update_method = self.async_update_firmware_available - async def _async_update_data(self) -> _DataT: - """Fetch the latest data from the source.""" - async with self._semaphore: - return await super()._async_update_data() + async def async_update_firmware_available(self) -> UpdateFirmwareCheck: + """Fetch data from API endpoint.""" + assert self.device.device + return await self.device.device.async_check_firmware_available() + + +class DevoloLedSettingsGetCoordinator(DevoloDataUpdateCoordinator[bool]): + """Class to manage fetching data from the LedSettingsGet endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + *, + config_entry: ConfigEntry, + name: str = SWITCH_LEDS, + update_interval: timedelta | None = SHORT_UPDATE_INTERVAL, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + config_entry=config_entry, + name=name, + update_interval=update_interval, + ) + self.update_method = self.async_update_led_status + + async def async_update_led_status(self) -> bool: + """Fetch data from API endpoint.""" + assert self.device.device + return await self.device.device.async_get_led_setting() + + +class DevoloLogicalNetworkCoordinator(DevoloDataUpdateCoordinator[LogicalNetwork]): + """Class to manage fetching data from the GetNetworkOverview endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + *, + config_entry: ConfigEntry, + name: str = CONNECTED_PLC_DEVICES, + update_interval: timedelta | None = LONG_UPDATE_INTERVAL, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + config_entry=config_entry, + name=name, + update_interval=update_interval, + ) + self.update_method = self.async_update_connected_plc_devices + + async def async_update_connected_plc_devices(self) -> LogicalNetwork: + """Fetch data from API endpoint.""" + assert self.device.plcnet + return await self.device.plcnet.async_get_network_overview() + + +class DevoloUptimeGetCoordinator(DevoloDataUpdateCoordinator[int]): + """Class to manage fetching data from the UptimeGet endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + *, + config_entry: ConfigEntry, + name: str = LAST_RESTART, + update_interval: timedelta | None = SHORT_UPDATE_INTERVAL, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + config_entry=config_entry, + name=name, + update_interval=update_interval, + ) + self.update_method = self.async_update_last_restart + + async def async_update_last_restart(self) -> int: + """Fetch data from API endpoint.""" + assert self.device.device + return await self.device.device.async_uptime() + + +class DevoloWifiConnectedStationsGetCoordinator( + DevoloDataUpdateCoordinator[list[ConnectedStationInfo]] +): + """Class to manage fetching data from the WifiGuestAccessGet endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + *, + config_entry: ConfigEntry, + name: str = CONNECTED_WIFI_CLIENTS, + update_interval: timedelta | None = SHORT_UPDATE_INTERVAL, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + config_entry=config_entry, + name=name, + update_interval=update_interval, + ) + self.update_method = self.async_get_wifi_connected_station + + async def async_get_wifi_connected_station(self) -> list[ConnectedStationInfo]: + """Fetch data from API endpoint.""" + assert self.device.device + return await self.device.device.async_get_wifi_connected_station() + + +class DevoloWifiGuestAccessGetCoordinator( + DevoloDataUpdateCoordinator[WifiGuestAccessGet] +): + """Class to manage fetching data from the WifiGuestAccessGet endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + *, + config_entry: ConfigEntry, + name: str = SWITCH_GUEST_WIFI, + update_interval: timedelta | None = SHORT_UPDATE_INTERVAL, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + config_entry=config_entry, + name=name, + update_interval=update_interval, + ) + self.update_method = self.async_update_guest_wifi_status + + async def async_update_guest_wifi_status(self) -> WifiGuestAccessGet: + """Fetch data from API endpoint.""" + assert self.device.device + return await self.device.device.async_get_wifi_guest_access() + + +class DevoloWifiNeighborAPsGetCoordinator( + DevoloDataUpdateCoordinator[list[NeighborAPInfo]] +): + """Class to manage fetching data from the WifiNeighborAPsGet endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + *, + config_entry: ConfigEntry, + name: str = NEIGHBORING_WIFI_NETWORKS, + update_interval: timedelta | None = LONG_UPDATE_INTERVAL, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + config_entry=config_entry, + name=name, + update_interval=update_interval, + ) + self.update_method = self.async_update_wifi_neighbor_access_points + + async def async_update_wifi_neighbor_access_points(self) -> list[NeighborAPInfo]: + """Fetch data from API endpoint.""" + assert self.device.device + return await self.device.device.async_get_wifi_neighbor_access_points() + + +@dataclass +class DevoloHomeNetworkData: + """The devolo Home Network data.""" + + device: Device + coordinators: dict[str, DevoloDataUpdateCoordinator[Any]] diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index cb726e5954c..15ff0e5ac2a 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -15,9 +15,8 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DevoloHomeNetworkConfigEntry from .const import CONNECTED_WIFI_CLIENTS, DOMAIN, WIFI_APTYPE, WIFI_BANDS -from .coordinator import DevoloDataUpdateCoordinator +from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/devolo_home_network/diagnostics.py b/homeassistant/components/devolo_home_network/diagnostics.py index 9cfc8a2c260..1683edb4074 100644 --- a/homeassistant/components/devolo_home_network/diagnostics.py +++ b/homeassistant/components/devolo_home_network/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from . import DevoloHomeNetworkConfigEntry +from .coordinator import DevoloHomeNetworkConfigEntry TO_REDACT = {CONF_PASSWORD} diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 64d8ff131e8..be437314ae4 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -15,9 +15,8 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN -from .coordinator import DevoloDataUpdateCoordinator +from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry type _DataType = ( LogicalNetwork diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 46a3eb3426a..8dc701a30c9 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -15,9 +15,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from . import DevoloHomeNetworkConfigEntry from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI -from .coordinator import DevoloDataUpdateCoordinator +from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry from .entity import DevoloCoordinatorEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index cec1ecc8a81..f4c911bf787 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow -from . import DevoloHomeNetworkConfigEntry from .const import ( CONNECTED_PLC_DEVICES, CONNECTED_WIFI_CLIENTS, @@ -31,7 +30,7 @@ from .const import ( PLC_RX_RATE, PLC_TX_RATE, ) -from .coordinator import DevoloDataUpdateCoordinator +from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry from .entity import DevoloCoordinatorEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index b57305a7a77..e709d0f54b4 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -16,9 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS -from .coordinator import DevoloDataUpdateCoordinator +from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry from .entity import DevoloCoordinatorEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index aaaf72af359..ace12f24358 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -21,9 +21,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, REGULAR_FIRMWARE -from .coordinator import DevoloDataUpdateCoordinator +from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry from .entity import DevoloCoordinatorEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 76d11f22424..70340c81f2f 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -7,6 +7,7 @@ from collections.abc import Callable from datetime import timedelta from fnmatch import translate from functools import lru_cache, partial +from ipaddress import IPv4Address import itertools import logging import re @@ -22,6 +23,7 @@ from aiodiscover.discovery import ( from cached_ipaddress import cached_ip_addresses from homeassistant import config_entries +from homeassistant.components import network from homeassistant.components.device_tracker import ( ATTR_HOST_NAME, ATTR_IP, @@ -421,9 +423,33 @@ class DHCPWatcher(WatcherBase): response.ip_address, response.hostname, response.mac_address ) + async def async_get_adapter_indexes(self) -> list[int] | None: + """Get the adapter indexes.""" + adapters = await network.async_get_adapters(self.hass) + if network.async_only_default_interface_enabled(adapters): + return None + return [ + adapter["index"] + for adapter in adapters + if ( + adapter["enabled"] + and adapter["index"] is not None + and adapter["ipv4"] + and ( + addresses := [IPv4Address(ip["address"]) for ip in adapter["ipv4"]] + ) + and any( + ip for ip in addresses if not ip.is_loopback and not ip.is_global + ) + ) + ] + async def async_start(self) -> None: """Start watching for dhcp packets.""" - self._unsub = await aiodhcpwatcher.async_start(self._async_process_dhcp_request) + self._unsub = await aiodhcpwatcher.async_start( + self._async_process_dhcp_request, + await self.async_get_adapter_indexes(), + ) class RediscoveryWatcher(WatcherBase): diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index c425aafdb00..ea2a4f4f820 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -2,6 +2,7 @@ "domain": "dhcp", "name": "DHCP Discovery", "codeowners": ["@bdraco"], + "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/dhcp", "integration_type": "system", "iot_class": "local_push", @@ -14,7 +15,7 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==1.1.1", + "aiodhcpwatcher==1.2.0", "aiodiscover==2.7.0", "cached-ipaddress==0.10.0" ] diff --git a/homeassistant/components/dlib_face_detect/__init__.py b/homeassistant/components/dlib_face_detect/__init__.py index a732132955f..0de082595ea 100644 --- a/homeassistant/components/dlib_face_detect/__init__.py +++ b/homeassistant/components/dlib_face_detect/__init__.py @@ -1 +1,3 @@ """The dlib_face_detect component.""" + +DOMAIN = "dlib_face_detect" diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py index 80becdf9992..9bd78f89653 100644 --- a/homeassistant/components/dlib_face_detect/image_processing.py +++ b/homeassistant/components/dlib_face_detect/image_processing.py @@ -11,10 +11,17 @@ from homeassistant.components.image_processing import ( ImageProcessingFaceEntity, ) from homeassistant.const import ATTR_LOCATION, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + split_entity_id, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import DOMAIN + PLATFORM_SCHEMA = IMAGE_PROCESSING_PLATFORM_SCHEMA @@ -25,37 +32,42 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Dlib Face detection platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Dlib Face Detect", + }, + ) + source: list[dict[str, str]] = config[CONF_SOURCE] add_entities( DlibFaceDetectEntity(camera[CONF_ENTITY_ID], camera.get(CONF_NAME)) - for camera in config[CONF_SOURCE] + for camera in source ) class DlibFaceDetectEntity(ImageProcessingFaceEntity): """Dlib Face API entity for identify.""" - def __init__(self, camera_entity, name=None): + def __init__(self, camera_entity: str, name: str | None) -> None: """Initialize Dlib face entity.""" super().__init__() - self._camera = camera_entity + self._attr_camera_entity = camera_entity if name: - self._name = name + self._attr_name = name else: - self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}" + self._attr_name = f"Dlib Face {split_entity_id(camera_entity)[1]}" - @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - def process_image(self, image): + def process_image(self, image: bytes) -> None: """Process image.""" fak_file = io.BytesIO(image) diff --git a/homeassistant/components/dlib_face_identify/__init__.py b/homeassistant/components/dlib_face_identify/__init__.py index 79b9e4ec4bc..0e682d6b839 100644 --- a/homeassistant/components/dlib_face_identify/__init__.py +++ b/homeassistant/components/dlib_face_identify/__init__.py @@ -1 +1,4 @@ """The dlib_face_identify component.""" + +CONF_FACES = "faces" +DOMAIN = "dlib_face_identify" diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index fee9f8dab3c..c7c512c16d9 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -11,17 +11,24 @@ import voluptuous as vol from homeassistant.components.image_processing import ( CONF_CONFIDENCE, PLATFORM_SCHEMA as IMAGE_PROCESSING_PLATFORM_SCHEMA, + FaceInformation, ImageProcessingFaceEntity, ) from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + split_entity_id, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import CONF_FACES, DOMAIN + _LOGGER = logging.getLogger(__name__) -CONF_FACES = "faces" PLATFORM_SCHEMA = IMAGE_PROCESSING_PLATFORM_SCHEMA.extend( { @@ -38,31 +45,55 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Dlib Face detection platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Dlib Face Identify", + }, + ) + + confidence: float = config[CONF_CONFIDENCE] + faces: dict[str, str] = config[CONF_FACES] + source: list[dict[str, str]] = config[CONF_SOURCE] add_entities( DlibFaceIdentifyEntity( camera[CONF_ENTITY_ID], - config[CONF_FACES], + faces, camera.get(CONF_NAME), - config[CONF_CONFIDENCE], + confidence, ) - for camera in config[CONF_SOURCE] + for camera in source ) class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): """Dlib Face API entity for identify.""" - def __init__(self, camera_entity, faces, name, tolerance): + def __init__( + self, + camera_entity: str, + faces: dict[str, str], + name: str | None, + tolerance: float, + ) -> None: """Initialize Dlib face identify entry.""" super().__init__() - self._camera = camera_entity + self._attr_camera_entity = camera_entity if name: - self._name = name + self._attr_name = name else: - self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}" + self._attr_name = f"Dlib Face {split_entity_id(camera_entity)[1]}" self._faces = {} for face_name, face_file in faces.items(): @@ -74,17 +105,7 @@ class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): self._tolerance = tolerance - @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - def process_image(self, image): + def process_image(self, image: bytes) -> None: """Process image.""" fak_file = io.BytesIO(image) @@ -94,7 +115,7 @@ class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): image = face_recognition.load_image_file(fak_file) unknowns = face_recognition.face_encodings(image) - found = [] + found: list[FaceInformation] = [] for unknown_face in unknowns: for name, face in self._faces.items(): result = face_recognition.compare_faces( diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index e7b60d5bd6f..6b86f1627bc 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import contextlib -from typing import Any +from typing import Any, Literal import aiodns from aiodns.error import DNSError @@ -62,16 +62,16 @@ async def async_validate_hostname( """Validate hostname.""" async def async_check( - hostname: str, resolver: str, qtype: str, port: int = 53 + hostname: str, resolver: str, qtype: Literal["A", "AAAA"], port: int = 53 ) -> bool: """Return if able to resolve hostname.""" - result = False + result: bool = False with contextlib.suppress(DNSError): - result = bool( - await aiodns.DNSResolver( # type: ignore[call-overload] - nameservers=[resolver], udp_port=port, tcp_port=port - ).query(hostname, qtype) + _resolver = aiodns.DNSResolver( + nameservers=[resolver], udp_port=port, tcp_port=port ) + result = bool(await _resolver.query(hostname, qtype)) + return result result: dict[str, bool] = {} diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index bcc6e7a8050..a00f942ec61 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -6,6 +6,7 @@ import io import logging import os import time +from typing import Any from PIL import Image, ImageDraw, UnidentifiedImageError from pydoods import PyDOODS @@ -88,10 +89,11 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Doods client.""" - url = config[CONF_URL] - auth_key = config[CONF_AUTH_KEY] - detector_name = config[CONF_DETECTOR] - timeout = config[CONF_TIMEOUT] + url: str = config[CONF_URL] + auth_key: str = config[CONF_AUTH_KEY] + detector_name: str = config[CONF_DETECTOR] + source: list[dict[str, str]] = config[CONF_SOURCE] + timeout: int = config[CONF_TIMEOUT] doods = PyDOODS(url, auth_key, timeout) response = doods.get_detectors() @@ -113,31 +115,35 @@ def setup_platform( add_entities( Doods( - hass, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), doods, detector, config, ) - for camera in config[CONF_SOURCE] + for camera in source ) class Doods(ImageProcessingEntity): """Doods image processing service client.""" - def __init__(self, hass, camera_entity, name, doods, detector, config): + def __init__( + self, + camera_entity: str, + name: str | None, + doods: PyDOODS, + detector: dict[str, Any], + config: dict[str, Any], + ) -> None: """Initialize the DOODS entity.""" - self.hass = hass - self._camera_entity = camera_entity + self._attr_camera_entity = camera_entity if name: - self._name = name + self._attr_name = name else: - name = split_entity_id(camera_entity)[1] - self._name = f"Doods {name}" + self._attr_name = f"Doods {split_entity_id(camera_entity)[1]}" self._doods = doods - self._file_out = config[CONF_FILE_OUT] + self._file_out: list[template.Template] = config[CONF_FILE_OUT] self._detector_name = detector["name"] # detector config and aspect ratio @@ -150,16 +156,16 @@ class Doods(ImageProcessingEntity): self._aspect = self._width / self._height # the base confidence - dconfig = {} - confidence = config[CONF_CONFIDENCE] + dconfig: dict[str, float] = {} + confidence: float = config[CONF_CONFIDENCE] # handle labels and specific detection areas - labels = config[CONF_LABELS] + labels: list[str | dict[str, Any]] = config[CONF_LABELS] self._label_areas = {} self._label_covers = {} for label in labels: if isinstance(label, dict): - label_name = label[CONF_NAME] + label_name: str = label[CONF_NAME] if label_name not in detector["labels"] and label_name != "*": _LOGGER.warning("Detector does not support label %s", label_name) continue @@ -207,28 +213,18 @@ class Doods(ImageProcessingEntity): self._covers = area_config[CONF_COVERS] self._dconfig = dconfig - self._matches = {} + self._matches: dict[str, list[dict[str, Any]]] = {} self._total_matches = 0 self._last_image = None - self._process_time = 0 + self._process_time = 0.0 @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera_entity - - @property - def name(self): - """Return the name of the image processor.""" - return self._name - - @property - def state(self): + def state(self) -> int: """Return the state of the entity.""" return self._total_matches @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" return { ATTR_MATCHES: self._matches, @@ -281,7 +277,7 @@ class Doods(ImageProcessingEntity): os.makedirs(os.path.dirname(path), exist_ok=True) img.save(path) - def process_image(self, image): + def process_image(self, image: bytes) -> None: """Process the image.""" try: img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") @@ -312,7 +308,7 @@ class Doods(ImageProcessingEntity): time.monotonic() - start, ) - matches = {} + matches: dict[str, list[dict[str, Any]]] = {} total_matches = 0 if not response or "error" in response: @@ -382,9 +378,7 @@ class Doods(ImageProcessingEntity): paths = [] for path_template in self._file_out: if isinstance(path_template, template.Template): - paths.append( - path_template.render(camera_entity=self._camera_entity) - ) + paths.append(path_template.render(camera_entity=self.camera_entity)) else: paths.append(path_template) self._save_image(image, matches, paths) diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index ad43e8c1c1c..285b544e465 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -3,10 +3,10 @@ "step": { "init": { "data": { - "events": "Comma separated list of events." + "events": "Comma-separated list of events." }, "data_description": { - "events": "Add a comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion" + "events": "Add a comma-separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion" } } } diff --git a/homeassistant/components/dovado/notify.py b/homeassistant/components/dovado/notify.py index 556848bf89f..0b74f97d06f 100644 --- a/homeassistant/components/dovado/notify.py +++ b/homeassistant/components/dovado/notify.py @@ -8,7 +8,7 @@ from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DOVADO_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -19,7 +19,7 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> DovadoSMSNotificationService: """Get the Dovado Router SMS notification service.""" - return DovadoSMSNotificationService(hass.data[DOVADO_DOMAIN].client) + return DovadoSMSNotificationService(hass.data[DOMAIN].client) class DovadoSMSNotificationService(BaseNotificationService): diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index e35fdeb2dc0..0129b990435 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DOVADO_DOMAIN +from . import DOMAIN MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) @@ -90,7 +90,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Dovado sensor platform.""" - dovado = hass.data[DOVADO_DOMAIN] + dovado = hass.data[DOMAIN] sensors = config[CONF_SENSORS] entities = [ diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 1a45886879a..c4fc8d2f500 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -2,32 +2,13 @@ from __future__ import annotations -from http import HTTPStatus import os -import re -import threading - -import requests -import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.service import async_register_admin_service -from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path +from homeassistant.core import HomeAssistant -from .const import ( - _LOGGER, - ATTR_FILENAME, - ATTR_OVERWRITE, - ATTR_SUBDIR, - ATTR_URL, - CONF_DOWNLOAD_DIR, - DOMAIN, - DOWNLOAD_COMPLETED_EVENT, - DOWNLOAD_FAILED_EVENT, - SERVICE_DOWNLOAD_FILE, -) +from .const import _LOGGER, CONF_DOWNLOAD_DIR +from .services import register_services async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -44,127 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - def download_file(service: ServiceCall) -> None: - """Start thread to download file specified in the URL.""" - - def do_download() -> None: - """Download the file.""" - try: - url = service.data[ATTR_URL] - - subdir = service.data.get(ATTR_SUBDIR) - - filename = service.data.get(ATTR_FILENAME) - - overwrite = service.data.get(ATTR_OVERWRITE) - - if subdir: - # Check the path - raise_if_invalid_path(subdir) - - final_path = None - - req = requests.get(url, stream=True, timeout=10) - - if req.status_code != HTTPStatus.OK: - _LOGGER.warning( - "Downloading '%s' failed, status_code=%d", url, req.status_code - ) - hass.bus.fire( - f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", - {"url": url, "filename": filename}, - ) - - else: - if filename is None and "content-disposition" in req.headers: - match = re.findall( - r"filename=(\S+)", req.headers["content-disposition"] - ) - - if match: - filename = match[0].strip("'\" ") - - if not filename: - filename = os.path.basename(url).strip() - - if not filename: - filename = "ha_download" - - # Check the filename - raise_if_invalid_filename(filename) - - # Do we want to download to subdir, create if needed - if subdir: - subdir_path = os.path.join(download_path, subdir) - - # Ensure subdir exist - os.makedirs(subdir_path, exist_ok=True) - - final_path = os.path.join(subdir_path, filename) - - else: - final_path = os.path.join(download_path, filename) - - path, ext = os.path.splitext(final_path) - - # If file exist append a number. - # We test filename, filename_2.. - if not overwrite: - tries = 1 - final_path = path + ext - while os.path.isfile(final_path): - tries += 1 - - final_path = f"{path}_{tries}.{ext}" - - _LOGGER.debug("%s -> %s", url, final_path) - - with open(final_path, "wb") as fil: - for chunk in req.iter_content(1024): - fil.write(chunk) - - _LOGGER.debug("Downloading of %s done", url) - hass.bus.fire( - f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", - {"url": url, "filename": filename}, - ) - - except requests.exceptions.ConnectionError: - _LOGGER.exception("ConnectionError occurred for %s", url) - hass.bus.fire( - f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", - {"url": url, "filename": filename}, - ) - - # Remove file if we started downloading but failed - if final_path and os.path.isfile(final_path): - os.remove(final_path) - except ValueError: - _LOGGER.exception("Invalid value") - hass.bus.fire( - f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", - {"url": url, "filename": filename}, - ) - - # Remove file if we started downloading but failed - if final_path and os.path.isfile(final_path): - os.remove(final_path) - - threading.Thread(target=do_download).start() - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_DOWNLOAD_FILE, - download_file, - schema=vol.Schema( - { - vol.Optional(ATTR_FILENAME): cv.string, - vol.Optional(ATTR_SUBDIR): cv.string, - vol.Required(ATTR_URL): cv.url, - vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, - } - ), - ) + register_services(hass) return True diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py new file mode 100644 index 00000000000..a8bcba605d9 --- /dev/null +++ b/homeassistant/components/downloader/services.py @@ -0,0 +1,159 @@ +"""Support for functionality to download files.""" + +from __future__ import annotations + +from http import HTTPStatus +import os +import re +import threading + +import requests +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_register_admin_service +from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path + +from .const import ( + _LOGGER, + ATTR_FILENAME, + ATTR_OVERWRITE, + ATTR_SUBDIR, + ATTR_URL, + CONF_DOWNLOAD_DIR, + DOMAIN, + DOWNLOAD_COMPLETED_EVENT, + DOWNLOAD_FAILED_EVENT, + SERVICE_DOWNLOAD_FILE, +) + + +def download_file(service: ServiceCall) -> None: + """Start thread to download file specified in the URL.""" + + entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0] + download_path = entry.data[CONF_DOWNLOAD_DIR] + + def do_download() -> None: + """Download the file.""" + try: + url = service.data[ATTR_URL] + + subdir = service.data.get(ATTR_SUBDIR) + + filename = service.data.get(ATTR_FILENAME) + + overwrite = service.data.get(ATTR_OVERWRITE) + + if subdir: + # Check the path + raise_if_invalid_path(subdir) + + final_path = None + + req = requests.get(url, stream=True, timeout=10) + + if req.status_code != HTTPStatus.OK: + _LOGGER.warning( + "Downloading '%s' failed, status_code=%d", url, req.status_code + ) + service.hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) + + else: + if filename is None and "content-disposition" in req.headers: + match = re.findall( + r"filename=(\S+)", req.headers["content-disposition"] + ) + + if match: + filename = match[0].strip("'\" ") + + if not filename: + filename = os.path.basename(url).strip() + + if not filename: + filename = "ha_download" + + # Check the filename + raise_if_invalid_filename(filename) + + # Do we want to download to subdir, create if needed + if subdir: + subdir_path = os.path.join(download_path, subdir) + + # Ensure subdir exist + os.makedirs(subdir_path, exist_ok=True) + + final_path = os.path.join(subdir_path, filename) + + else: + final_path = os.path.join(download_path, filename) + + path, ext = os.path.splitext(final_path) + + # If file exist append a number. + # We test filename, filename_2.. + if not overwrite: + tries = 1 + final_path = path + ext + while os.path.isfile(final_path): + tries += 1 + + final_path = f"{path}_{tries}.{ext}" + + _LOGGER.debug("%s -> %s", url, final_path) + + with open(final_path, "wb") as fil: + for chunk in req.iter_content(1024): + fil.write(chunk) + + _LOGGER.debug("Downloading of %s done", url) + service.hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", + {"url": url, "filename": filename}, + ) + + except requests.exceptions.ConnectionError: + _LOGGER.exception("ConnectionError occurred for %s", url) + service.hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) + + # Remove file if we started downloading but failed + if final_path and os.path.isfile(final_path): + os.remove(final_path) + except ValueError: + _LOGGER.exception("Invalid value") + service.hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) + + # Remove file if we started downloading but failed + if final_path and os.path.isfile(final_path): + os.remove(final_path) + + threading.Thread(target=do_download).start() + + +def register_services(hass: HomeAssistant) -> None: + """Register the services for the downloader component.""" + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DOWNLOAD_FILE, + download_file, + schema=vol.Schema( + { + vol.Optional(ATTR_FILENAME): cv.string, + vol.Optional(ATTR_SUBDIR): cv.string, + vol.Required(ATTR_URL): cv.url, + vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, + } + ), + ) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index ba528271824..918d4e33971 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -572,7 +572,7 @@ def device_class_and_uom( ) -> tuple[SensorDeviceClass | None, str | None]: """Get native unit of measurement from telegram,.""" dsmr_object = getattr(data, entity_description.obis_reference) - uom: str | None = getattr(dsmr_object, "unit") or None + uom: str | None = dsmr_object.unit or None with suppress(ValueError): if entity_description.device_class == SensorDeviceClass.GAS and ( enery_uom := UnitOfEnergy(str(uom)) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index 56a98c8d630..81fc7ceb298 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -53,7 +53,6 @@ SUPPORT_FLAGS_THERMOSTAT = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.AUX_HEAT ) @@ -148,11 +147,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): if target_temp_low or target_temp_high: self._econet.set_set_point(None, target_temp_high, target_temp_low) - @property - def is_aux_heat(self) -> bool: - """Return true if aux heater.""" - return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT - @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool, mode. @@ -211,12 +205,12 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode]) @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self._econet.set_point_limits[0] @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self._econet.set_point_limits[1] diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 73b21d4574d..7c85a63cc78 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT -from .util import get_supported_entitites +from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -49,7 +49,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" async_add_entities( - get_supported_entitites( + get_supported_entities( config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS ) ) diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py index 04eb0af02e6..ba1a0847408 100644 --- a/homeassistant/components/ecovacs/button.py +++ b/homeassistant/components/ecovacs/button.py @@ -16,13 +16,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS +from .const import SUPPORTED_LIFESPANS from .entity import ( EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, ) -from .util import get_supported_entitites +from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -62,7 +62,7 @@ STATION_ENTITY_DESCRIPTIONS = tuple( key=f"station_action_{action.name.lower()}", translation_key=f"station_action_{action.name.lower()}", ) - for action in SUPPORTED_STATION_ACTIONS + for action in StationAction ) @@ -85,7 +85,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities: list[EcovacsEntity] = get_supported_entitites( + entities: list[EcovacsEntity] = get_supported_entities( controller, EcovacsButtonEntity, ENTITY_DESCRIPTIONS ) entities.extend( diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index c2daf3a7e90..8a7388da735 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==13.2.1"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.3.0"] } diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index 7a74b02ceca..1fbf65aec65 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -25,7 +25,7 @@ from .entity import ( EcovacsEntity, EventT, ) -from .util import get_supported_entitites +from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -87,7 +87,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities: list[EcovacsEntity] = get_supported_entitites( + entities: list[EcovacsEntity] = get_supported_entities( controller, EcovacsNumberEntity, ENTITY_DESCRIPTIONS ) if entities: diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index 31292401343..deddb7e252a 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT -from .util import get_name_key, get_supported_entitites +from .util import get_name_key, get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -59,7 +59,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities = get_supported_entitites( + entities = get_supported_entities( controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS ) if entities: diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 3da1db23b24..98f3783b231 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -48,7 +48,7 @@ from .entity import ( EcovacsLegacyEntity, EventT, ) -from .util import get_name_key, get_options, get_supported_entitites +from .util import get_name_key, get_options, get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -210,7 +210,7 @@ async def async_setup_entry( """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities: list[EcovacsEntity] = get_supported_entitites( + entities: list[EcovacsEntity] = get_supported_entities( controller, EcovacsSensor, ENTITY_DESCRIPTIONS ) entities.extend( diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py index dd379dbb199..d151b55ca1c 100644 --- a/homeassistant/components/ecovacs/switch.py +++ b/homeassistant/components/ecovacs/switch.py @@ -17,7 +17,7 @@ from .entity import ( EcovacsDescriptionEntity, EcovacsEntity, ) -from .util import get_supported_entitites +from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -109,7 +109,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities: list[EcovacsEntity] = get_supported_entitites( + entities: list[EcovacsEntity] = get_supported_entities( controller, EcovacsSwitchEntity, ENTITY_DESCRIPTIONS ) if entities: diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index 0cfbf1e8f91..968ab92851b 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -32,7 +32,7 @@ def get_client_device_id(hass: HomeAssistant, self_hosted: bool) -> str: ) -def get_supported_entitites( +def get_supported_entities( controller: EcovacsController, entity_class: type[EcovacsDescriptionEntity], descriptions: tuple[EcovacsCapabilityEntityDescription, ...], diff --git a/homeassistant/components/eddystone_temperature/__init__.py b/homeassistant/components/eddystone_temperature/__init__.py index 2d6f92498bd..af37eb629b5 100644 --- a/homeassistant/components/eddystone_temperature/__init__.py +++ b/homeassistant/components/eddystone_temperature/__init__.py @@ -1 +1,6 @@ """The eddystone_temperature component.""" + +DOMAIN = "eddystone_temperature" +CONF_BEACONS = "beacons" +CONF_INSTANCE = "instance" +CONF_NAMESPACE = "namespace" diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 1047c52e111..7b8e726cf45 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -23,17 +23,18 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import CONF_BEACONS, CONF_INSTANCE, CONF_NAMESPACE, DOMAIN + _LOGGER = logging.getLogger(__name__) -CONF_BEACONS = "beacons" CONF_BT_DEVICE_ID = "bt_device_id" -CONF_INSTANCE = "instance" -CONF_NAMESPACE = "namespace" + BEACON_SCHEMA = vol.Schema( { @@ -58,6 +59,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Validate configuration, create devices and start monitoring thread.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Eddystone", + }, + ) + bt_device_id: int = config[CONF_BT_DEVICE_ID] beacons: dict[str, dict[str, str]] = config[CONF_BEACONS] diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 881396ea4af..bc8bbded186 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -13,6 +13,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME, diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 3cde9e758cd..7ac0b897507 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -4,7 +4,7 @@ from typing import Any from eheimdigital.device import EheimDigitalDevice from eheimdigital.heater import EheimDigitalHeater -from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit +from eheimdigital.types import HeaterMode, HeaterUnit from homeassistant.components.climate import ( PRESET_NONE, @@ -20,12 +20,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -from .entity import EheimDigitalEntity +from .entity import EheimDigitalEntity, exception_handler # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -83,34 +82,28 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE self._attr_unique_id = self._device_address self._async_update_attrs() + @exception_handler async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" - try: - if preset_mode in HEATER_PRESET_TO_HEATER_MODE: - await self._device.set_operation_mode( - HEATER_PRESET_TO_HEATER_MODE[preset_mode] - ) - except EheimDigitalClientError as err: - raise HomeAssistantError from err + if preset_mode in HEATER_PRESET_TO_HEATER_MODE: + await self._device.set_operation_mode( + HEATER_PRESET_TO_HEATER_MODE[preset_mode] + ) + @exception_handler async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new temperature.""" - try: - if ATTR_TEMPERATURE in kwargs: - await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE]) - except EheimDigitalClientError as err: - raise HomeAssistantError from err + if ATTR_TEMPERATURE in kwargs: + await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE]) + @exception_handler async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the heating mode.""" - try: - match hvac_mode: - case HVACMode.OFF: - await self._device.set_active(active=False) - case HVACMode.AUTO: - await self._device.set_active(active=True) - except EheimDigitalClientError as err: - raise HomeAssistantError from err + match hvac_mode: + case HVACMode.OFF: + await self._device.set_active(active=False) + case HVACMode.AUTO: + await self._device.set_active(active=True) def _async_update_attrs(self) -> None: if self._device.temperature_unit == HeaterUnit.CELSIUS: diff --git a/homeassistant/components/eheimdigital/diagnostics.py b/homeassistant/components/eheimdigital/diagnostics.py new file mode 100644 index 00000000000..208131beabe --- /dev/null +++ b/homeassistant/components/eheimdigital/diagnostics.py @@ -0,0 +1,19 @@ +"""Diagnostics for the EHEIM Digital integration.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import EheimDigitalConfigEntry + +TO_REDACT = {"emailAddr", "usrName"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: EheimDigitalConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return async_redact_data( + {"entry": entry.as_dict(), "data": entry.runtime_data.data}, TO_REDACT + ) diff --git a/homeassistant/components/eheimdigital/entity.py b/homeassistant/components/eheimdigital/entity.py index c0f91a4b798..d28087ef82e 100644 --- a/homeassistant/components/eheimdigital/entity.py +++ b/homeassistant/components/eheimdigital/entity.py @@ -1,12 +1,15 @@ """Base entity for EHEIM Digital.""" from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any, Concatenate from eheimdigital.device import EheimDigitalDevice +from eheimdigital.types import EheimDigitalClientError from homeassistant.const import CONF_HOST from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -51,3 +54,24 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice]( """Update attributes when the coordinator updates.""" self._async_update_attrs() super()._handle_coordinator_update() + + +def exception_handler[_EntityT: EheimDigitalEntity[EheimDigitalDevice], **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate AirGradient calls to handle exceptions. + + A decorator that wraps the passed in function, catches AirGradient errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except EheimDigitalClientError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json index 41a362c757c..cbe2613dd97 100644 --- a/homeassistant/components/eheimdigital/icons.json +++ b/homeassistant/components/eheimdigital/icons.json @@ -15,6 +15,12 @@ }, "night_temperature_offset": { "default": "mdi:thermometer" + }, + "system_led": { + "default": "mdi:led-on", + "state": { + "0": "mdi:led-off" + } } }, "sensor": { diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index 2725315befd..4e148ee5204 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -4,7 +4,7 @@ from typing import Any from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl from eheimdigital.device import EheimDigitalDevice -from eheimdigital.types import EheimDigitalClientError, LightMode +from eheimdigital.types import LightMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -15,13 +15,12 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -from .entity import EheimDigitalEntity +from .entity import EheimDigitalEntity, exception_handler BRIGHTNESS_SCALE = (1, 100) @@ -88,30 +87,22 @@ class EheimDigitalClassicLEDControlLight( """Return whether the entity is available.""" return super().available and self._device.light_level[self._channel] is not None + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" if ATTR_EFFECT in kwargs: await self._device.set_light_mode(EFFECT_TO_LIGHT_MODE[kwargs[ATTR_EFFECT]]) return if ATTR_BRIGHTNESS in kwargs: - if self._device.light_mode == LightMode.DAYCL_MODE: - await self._device.set_light_mode(LightMode.MAN_MODE) - try: - await self._device.turn_on( - int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])), - self._channel, - ) - except EheimDigitalClientError as err: - raise HomeAssistantError from err + await self._device.turn_on( + int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])), + self._channel, + ) + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - if self._device.light_mode == LightMode.DAYCL_MODE: - await self._device.set_light_mode(LightMode.MAN_MODE) - try: - await self._device.turn_off(self._channel) - except EheimDigitalClientError as err: - raise HomeAssistantError from err + await self._device.turn_off(self._channel) def _async_update_attrs(self) -> None: light_level = self._device.light_level[self._channel] diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index c3c8a251300..99f2a0a9c56 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.1.0"], + "requirements": ["eheimdigital==1.2.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 index f4504be624c..03f27aa82df 100644 --- a/homeassistant/components/eheimdigital/number.py +++ b/homeassistant/components/eheimdigital/number.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -from .entity import EheimDigitalEntity +from .entity import EheimDigitalEntity, exception_handler PARALLEL_UPDATES = 0 @@ -109,6 +109,20 @@ HEATER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalHeater], .. ), ) +GENERAL_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalDevice], ...] = ( + EheimDigitalNumberDescription[EheimDigitalDevice]( + key="system_led", + translation_key="system_led", + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=100, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.sys_led, + set_value_fn=lambda device, value: device.set_sys_led(int(value)), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -138,6 +152,10 @@ async def async_setup_entry( ) for description in HEATER_DESCRIPTIONS ) + entities.extend( + EheimDigitalNumber[EheimDigitalDevice](coordinator, device, description) + for description in GENERAL_DESCRIPTIONS + ) async_add_entities(entities) @@ -164,6 +182,7 @@ class EheimDigitalNumber( self._attr_unique_id = f"{self._device_address}_{description.key}" @override + @exception_handler async def async_set_native_value(self, value: float) -> None: return await self.entity_description.set_value_fn(self._device, value) diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml index a56551a14f6..c1490b352c2 100644 --- a/homeassistant/components/eheimdigital/quality_scale.yaml +++ b/homeassistant/components/eheimdigital/quality_scale.yaml @@ -43,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo @@ -58,7 +58,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/eheimdigital/select.py b/homeassistant/components/eheimdigital/select.py new file mode 100644 index 00000000000..41ab13e3bd4 --- /dev/null +++ b/homeassistant/components/eheimdigital/select.py @@ -0,0 +1,103 @@ +"""EHEIM Digital select entities.""" + +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.types import FilterMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity, exception_handler + +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital select entities.""" + + value_fn: Callable[[_DeviceT_co], str | None] + set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalSelectDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalSelectDescription[EheimDigitalClassicVario]( + key="filter_mode", + translation_key="filter_mode", + value_fn=( + lambda device: device.filter_mode.name.lower() + if device.filter_mode is not None + else None + ), + set_value_fn=( + lambda device, value: device.set_filter_mode(FilterMode[value.upper()]) + ), + options=[name.lower() for name in FilterMode.__members__], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so select entities 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[EheimDigitalSelect[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalSelect[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 EheimDigitalSelect( + EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co] +): + """Represent an EHEIM Digital select entity.""" + + entity_description: EheimDigitalSelectDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalSelectDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital select entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + @exception_handler + async def async_select_option(self, option: str) -> None: + return await self.entity_description.set_value_fn(self._device, option) + + @override + def _async_update_attrs(self) -> None: + self._attr_current_option = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index 97a3fbe4e0d..77cffb4a709 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -62,6 +62,19 @@ }, "night_temperature_offset": { "name": "Night temperature offset" + }, + "system_led": { + "name": "System LED brightness" + } + }, + "select": { + "filter_mode": { + "name": "Filter mode", + "state": { + "manual": "Manual", + "pulse": "Pulse", + "bio": "Bio" + } } }, "sensor": { @@ -88,5 +101,10 @@ "name": "Night start time" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the EHEIM Digital hub: {error}" + } } } diff --git a/homeassistant/components/eheimdigital/switch.py b/homeassistant/components/eheimdigital/switch.py index de23feff322..2a4f3df3861 100644 --- a/homeassistant/components/eheimdigital/switch.py +++ b/homeassistant/components/eheimdigital/switch.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -from .entity import EheimDigitalEntity +from .entity import EheimDigitalEntity, exception_handler # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -58,10 +58,12 @@ class EheimDigitalClassicVarioSwitch( self._async_update_attrs() @override + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: await self._device.set_active(active=False) @override + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: await self._device.set_active(active=True) diff --git a/homeassistant/components/eheimdigital/time.py b/homeassistant/components/eheimdigital/time.py index ae64fad0c92..49834c827b9 100644 --- a/homeassistant/components/eheimdigital/time.py +++ b/homeassistant/components/eheimdigital/time.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -from .entity import EheimDigitalEntity +from .entity import EheimDigitalEntity, exception_handler PARALLEL_UPDATES = 0 @@ -122,6 +122,7 @@ class EheimDigitalTime( self._attr_unique_id = f"{device.mac_address}_{description.key}" @override + @exception_handler async def async_set_value(self, value: time) -> None: """Change the time.""" return await self.entity_description.set_value_fn(self._device, value) diff --git a/homeassistant/components/electrasmart/strings.json b/homeassistant/components/electrasmart/strings.json index 06c7dfd6bed..485bf766534 100644 --- a/homeassistant/components/electrasmart/strings.json +++ b/homeassistant/components/electrasmart/strings.json @@ -3,12 +3,12 @@ "step": { "user": { "data": { - "phone_number": "Phone Number" + "phone_number": "Phone number" } }, "one_time_password": { "data": { - "one_time_password": "One Time Password" + "one_time_password": "One-time password" } } }, diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 55af0cfa29c..59d3aa9605a 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -20,10 +20,8 @@ from homeassistant.components.climate import ( from homeassistant.const import PRECISION_WHOLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from . import ElkM1ConfigEntry -from .const import DOMAIN from .entity import ElkEntity, create_elk_entities SUPPORT_HVAC = [ @@ -78,7 +76,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): _attr_precision = PRECISION_WHOLE _attr_supported_features = ( ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON @@ -128,11 +125,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): """Return the current humidity.""" return self._element.humidity - @property - def is_aux_heat(self) -> bool: - """Return if aux heater is on.""" - return self._element.mode == ThermostatMode.EMERGENCY_HEAT - @property def fan_mode(self) -> str | None: """Return the fan setting.""" @@ -151,34 +143,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): thermostat_mode, fan_mode = HASS_TO_ELK_HVAC_MODES[hvac_mode] self._elk_set(thermostat_mode, fan_mode) - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - async_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._elk_set(ThermostatMode.EMERGENCY_HEAT, None) - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - async_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._elk_set(ThermostatMode.HEAT, None) - async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" thermostat_mode, elk_fan_mode = HASS_TO_ELK_FAN_MODES[fan_mode] diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index b50c1817838..19967612b0f 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -189,18 +189,5 @@ "name": "Sensor zone trigger", "description": "Triggers zone." } - }, - "issues": { - "migrate_aux_heat": { - "title": "Migration of Elk-M1 set_aux_heat action", - "fix_flow": { - "step": { - "confirm": { - "description": "The Elk-M1 `set_aux_heat` action has been migrated. A new emergency heat switch entity is available for each thermostat.\n\nUpdate any automations to use the new emergency heat switch entity. When this is done, select **Submit** to fix this issue.", - "title": "[%key:component::elkm1::issues::migrate_aux_heat::title%]" - } - } - } - } } } diff --git a/homeassistant/components/emulated_roku/strings.json b/homeassistant/components/emulated_roku/strings.json index fe5f603b04a..e740f6d8f53 100644 --- a/homeassistant/components/emulated_roku/strings.json +++ b/homeassistant/components/emulated_roku/strings.json @@ -7,12 +7,12 @@ "step": { "user": { "data": { - "advertise_ip": "Advertise IP Address", - "advertise_port": "Advertise Port", - "host_ip": "Host IP Address", - "listen_port": "Listen Port", + "advertise_ip": "Advertise IP address", + "advertise_port": "Advertise port", + "host_ip": "Host IP address", + "listen_port": "Listen port", "name": "[%key:common::config_flow::data::name%]", - "upnp_bind_multicast": "Bind multicast (True/False)" + "upnp_bind_multicast": "Bind multicast" }, "title": "Define server configuration" } diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 062601eb4c5..3dc857d75d9 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -52,6 +52,7 @@ VALID_ENERGY_UNITS_GAS = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, *VALID_ENERGY_UNITS, } VALID_VOLUME_UNITS_WATER: set[str] = { diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index cfacbe48b97..0f46678994f 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -50,6 +50,7 @@ GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, ), } GAS_PRICE_UNITS = tuple( diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 40c690b29ec..cfff0777af5 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -180,9 +180,15 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) return - device_registry.async_update_device( - device_id=envoy_device.id, - new_connections={connection}, + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={ + ( + DOMAIN, + self.envoy_serial_number, + ) + }, + connections={connection}, ) _LOGGER.debug("added connection: %s to %s", connection, self.name) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index da0be245fcd..a6a6e447426 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.2"] + "requirements": ["env-canada==0.11.2"] } diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 3d82cfd7511..8e72457f4a7 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -11,7 +11,6 @@ from pyephember2.pyephember2 import ( ZoneMode, zone_current_temperature, zone_is_active, - zone_is_boost_active, zone_is_hotwater, zone_mode, zone_name, @@ -102,7 +101,6 @@ class EphEmberThermostat(ClimateEntity): self._attr_name = self._zone_name if self._hot_water: - self._attr_supported_features = ClimateEntityFeature.AUX_HEAT self._attr_target_temperature_step = None else: self._attr_target_temperature_step = 0.5 @@ -144,22 +142,6 @@ class EphEmberThermostat(ClimateEntity): else: _LOGGER.error("Invalid operation mode provided %s", hvac_mode) - @property - def is_aux_heat(self) -> bool: - """Return true if aux heater.""" - - return zone_is_boost_active(self._zone) - - def turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - self._ember.activate_boost_by_name( - self._zone_name, zone_target_temperature(self._zone) - ) - - def turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - self._ember.deactivate_boost_by_name(self._zone_name) - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: @@ -177,7 +159,7 @@ class EphEmberThermostat(ClimateEntity): self._ember.set_zone_target_temperature(self._zone["zoneid"], temperature) @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" # Hot water temp doesn't support being changed if self._hot_water: @@ -186,7 +168,7 @@ class EphEmberThermostat(ClimateEntity): return 5.0 @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" if self._hot_water: return zone_target_temperature(self._zone) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 1f619b2017c..889401ffc3e 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.15.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.16.0"] } diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 94c4a8ffe46..37f8e738aee 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -134,6 +134,22 @@ def esphome_state_property[_R, _EntityT: EsphomeEntity[Any, Any]]( return _wrapper +def async_esphome_state_property[_R, _EntityT: EsphomeEntity[Any, Any]]( + func: Callable[[_EntityT], Awaitable[_R | None]], +) -> Callable[[_EntityT], Coroutine[Any, Any, _R | None]]: + """Wrap a state property of an esphome entity. + + This checks if the state object in the entity is set + and returns None if it is not set. + """ + + @functools.wraps(func) + async def _wrapper(self: _EntityT) -> _R | None: + return await func(self) if self._has_state else None + + return _wrapper + + def esphome_float_state_property[_EntityT: EsphomeEntity[Any, Any]]( func: Callable[[_EntityT], float | None], ) -> Callable[[_EntityT], float | None]: @@ -210,6 +226,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): _static_info: _InfoT _state: _StateT _has_state: bool + unique_id: str def __init__( self, diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 023c6f70da4..1e6375d8caf 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass, field from functools import partial import logging +from operator import delitem from typing import TYPE_CHECKING, Any, Final, TypedDict, cast from aioesphomeapi import ( @@ -183,18 +184,7 @@ class RuntimeEntryData: """Register to receive callbacks when static info changes for an EntityInfo type.""" callbacks = self.entity_info_callbacks.setdefault(entity_info_type, []) callbacks.append(callback_) - return partial( - self._async_unsubscribe_register_static_info, callbacks, callback_ - ) - - @callback - def _async_unsubscribe_register_static_info( - self, - callbacks: list[Callable[[list[EntityInfo]], None]], - callback_: Callable[[list[EntityInfo]], None], - ) -> None: - """Unsubscribe to when static info is registered.""" - callbacks.remove(callback_) + return partial(callbacks.remove, callback_) @callback def async_register_key_static_info_updated_callback( @@ -206,18 +196,7 @@ class RuntimeEntryData: callback_key = (type(static_info), static_info.key) callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) callbacks.append(callback_) - return partial( - self._async_unsubscribe_static_key_info_updated, callbacks, callback_ - ) - - @callback - def _async_unsubscribe_static_key_info_updated( - self, - callbacks: list[Callable[[EntityInfo], None]], - callback_: Callable[[EntityInfo], None], - ) -> None: - """Unsubscribe to when static info is updated .""" - callbacks.remove(callback_) + return partial(callbacks.remove, callback_) @callback def async_set_assist_pipeline_state(self, state: bool) -> None: @@ -232,14 +211,7 @@ class RuntimeEntryData: ) -> CALLBACK_TYPE: """Subscribe to assist pipeline updates.""" self.assist_pipeline_update_callbacks.append(update_callback) - return partial(self._async_unsubscribe_assist_pipeline_update, update_callback) - - @callback - def _async_unsubscribe_assist_pipeline_update( - self, update_callback: CALLBACK_TYPE - ) -> None: - """Unsubscribe to assist pipeline updates.""" - self.assist_pipeline_update_callbacks.remove(update_callback) + return partial(self.assist_pipeline_update_callbacks.remove, update_callback) @callback def async_remove_entities( @@ -337,12 +309,7 @@ class RuntimeEntryData: def async_subscribe_device_updated(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: """Subscribe to state updates.""" self.device_update_subscriptions.add(callback_) - return partial(self._async_unsubscribe_device_update, callback_) - - @callback - def _async_unsubscribe_device_update(self, callback_: CALLBACK_TYPE) -> None: - """Unsubscribe to device updates.""" - self.device_update_subscriptions.remove(callback_) + return partial(self.device_update_subscriptions.remove, callback_) @callback def async_subscribe_static_info_updated( @@ -350,14 +317,7 @@ class RuntimeEntryData: ) -> CALLBACK_TYPE: """Subscribe to static info updates.""" self.static_info_update_subscriptions.add(callback_) - return partial(self._async_unsubscribe_static_info_updated, callback_) - - @callback - def _async_unsubscribe_static_info_updated( - self, callback_: Callable[[list[EntityInfo]], None] - ) -> None: - """Unsubscribe to static info updates.""" - self.static_info_update_subscriptions.remove(callback_) + return partial(self.static_info_update_subscriptions.remove, callback_) @callback def async_subscribe_state_update( @@ -369,14 +329,7 @@ class RuntimeEntryData: """Subscribe to state updates.""" subscription_key = (state_type, state_key) self.state_subscriptions[subscription_key] = entity_callback - return partial(self._async_unsubscribe_state_update, subscription_key) - - @callback - def _async_unsubscribe_state_update( - self, subscription_key: tuple[type[EntityState], int] - ) -> None: - """Unsubscribe to state updates.""" - self.state_subscriptions.pop(subscription_key) + return partial(delitem, self.state_subscriptions, subscription_key) @callback def async_update_state(self, state: EntityState) -> None: @@ -523,7 +476,7 @@ class RuntimeEntryData: ) -> CALLBACK_TYPE: """Register to receive callbacks when the Assist satellite's configuration is updated.""" self.assist_satellite_config_update_callbacks.append(callback_) - return lambda: self.assist_satellite_config_update_callbacks.remove(callback_) + return partial(self.assist_satellite_config_update_callbacks.remove, callback_) @callback def async_assist_satellite_config_updated( @@ -540,7 +493,7 @@ class RuntimeEntryData: ) -> CALLBACK_TYPE: """Register to receive callbacks when the Assist satellite's wake word is set.""" self.assist_satellite_set_wake_word_callbacks.append(callback_) - return lambda: self.assist_satellite_set_wake_word_callbacks.remove(callback_) + return partial(self.assist_satellite_set_wake_word_callbacks.remove, callback_) @callback def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None: diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 7cdc3570d61..a4d840845a6 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -63,7 +63,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): if self._supports_speed_levels: data["speed_level"] = math.ceil( percentage_to_ranged_value( - (1, self._static_info.supported_speed_levels), percentage + (1, self._static_info.supported_speed_count), percentage ) ) else: @@ -121,7 +121,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): ) return ranged_value_to_percentage( - (1, self._static_info.supported_speed_levels), self._state.speed_level + (1, self._static_info.supported_speed_count), self._state.speed_level ) @property @@ -164,7 +164,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): if not supports_speed_levels: self._attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) else: - self._attr_speed_count = static_info.supported_speed_levels + self._attr_speed_count = static_info.supported_speed_count async_setup_entry = partial( diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index d8d827f18a1..3e278b5b2d6 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, cast from aioesphomeapi import ( APIVersion, + ColorMode as ESPHomeColorMode, EntityInfo, LightColorCapability, LightInfo, @@ -106,15 +107,15 @@ def _mired_to_kelvin(mired_temperature: float) -> int: @lru_cache -def _color_mode_to_ha(mode: int) -> str: +def _color_mode_to_ha(mode: ESPHomeColorMode) -> ColorMode: """Convert an esphome color mode to a HA color mode constant. Choose the color mode that best matches the feature-set. """ - candidates = [] + candidates: list[tuple[ColorMode, LightColorCapability]] = [] for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items(): for caps in cap_lists: - if caps == mode: + if caps.value == mode: # exact match return ha_mode if (mode & caps) == caps: @@ -131,8 +132,8 @@ def _color_mode_to_ha(mode: int) -> str: @lru_cache def _filter_color_modes( - supported: list[int], features: LightColorCapability -) -> tuple[int, ...]: + supported: list[ESPHomeColorMode], features: LightColorCapability +) -> tuple[ESPHomeColorMode, ...]: """Filter the given supported color modes. Excluding all values that don't have the requested features. @@ -156,7 +157,7 @@ def _least_complex_color_mode(color_modes: tuple[int, ...]) -> int: class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """A light implementation for ESPHome.""" - _native_supported_color_modes: tuple[int, ...] + _native_supported_color_modes: tuple[ESPHomeColorMode, ...] _supports_color_mode = False @property diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 1b0e4fc8986..b4af39586d4 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio from functools import partial import logging -import re from typing import TYPE_CHECKING, Any, NamedTuple from aioesphomeapi import ( @@ -23,6 +22,7 @@ from aioesphomeapi import ( RequiresEncryptionAPIError, UserService, UserServiceArgType, + parse_log_message, ) from awesomeversion import AwesomeVersion import voluptuous as vol @@ -110,11 +110,6 @@ LOGGER_TO_LOG_LEVEL = { logging.ERROR: LogLevel.LOG_LEVEL_ERROR, logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR, } -# 7-bit and 8-bit C1 ANSI sequences -# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python -ANSI_ESCAPE_78BIT = re.compile( - rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])" -) @callback @@ -387,13 +382,15 @@ class ESPHomeManager: def _async_on_log(self, msg: SubscribeLogsResponse) -> None: """Handle a log message from the API.""" - log: bytes = msg.message - _LOGGER.log( - LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG), - "%s: %s", - self.entry.title, - ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), - ) + for line in parse_log_message( + msg.message.decode("utf-8", "backslashreplace"), "", strip_ansi_escapes=True + ): + _LOGGER.log( + LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG), + "%s: %s", + self.entry.title, + line, + ) @callback def _async_get_equivalent_log_level(self) -> LogLevel: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index beaf68decd9..9b70aba4de1 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,9 +17,9 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==30.1.0", + "aioesphomeapi==32.2.1", "esphome-dashboard-api==1.3.0", - "bleak-esphome==2.15.1" + "bleak-esphome==2.16.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 3af6c0b2049..f18b5e7bf5c 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -78,7 +78,7 @@ class EsphomeMediaPlayer( if self._static_info.supports_pause: flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY self._attr_supported_features = flags - self._entry_data.media_player_formats[static_info.unique_id] = cast( + self._entry_data.media_player_formats[self.unique_id] = cast( MediaPlayerInfo, static_info ).supported_formats @@ -114,9 +114,8 @@ class EsphomeMediaPlayer( media_id = async_process_play_media_url(self.hass, media_id) announcement = kwargs.get(ATTR_MEDIA_ANNOUNCE) bypass_proxy = kwargs.get(ATTR_MEDIA_EXTRA, {}).get(ATTR_BYPASS_PROXY) - supported_formats: list[MediaPlayerSupportedFormat] | None = ( - self._entry_data.media_player_formats.get(self._static_info.unique_id) + self._entry_data.media_player_formats.get(self.unique_id) ) if ( @@ -139,7 +138,7 @@ class EsphomeMediaPlayer( async def async_will_remove_from_hass(self) -> None: """Handle entity being removed.""" await super().async_will_remove_from_hass() - self._entry_data.media_player_formats.pop(self.entity_id, None) + self._entry_data.media_player_formats.pop(self.unique_id, None) def _get_proxy_url( self, diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 611d7056ff7..5baa092613b 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -88,9 +88,9 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): return if ( state_class == EsphomeSensorStateClass.MEASUREMENT - and static_info.last_reset_type == LastResetType.AUTO + and static_info.legacy_last_reset_type == LastResetType.AUTO ): - # Legacy, last_reset_type auto was the equivalent to the + # Legacy, legacy_last_reset_type auto was the equivalent to the # TOTAL_INCREASING state class self._attr_state_class = SensorStateClass.TOTAL_INCREASING else: diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index bc198d514ab..eab88e8df95 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -195,7 +195,10 @@ "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." + "message": "Error during OTA (Over-The-Air) of {configuration}; Try again in ESPHome dashboard for more information." + }, + "ota_in_progress": { + "message": "An OTA (Over-The-Air) update is already in progress for {configuration}." } } } diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 01ac638bdb1..cc886f2ba4c 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -31,6 +31,7 @@ from .coordinator import ESPHomeDashboardCoordinator from .dashboard import async_get_dashboard from .entity import ( EsphomeEntity, + async_esphome_state_property, convert_api_error_ha_error, esphome_state_property, platform_async_setup_entry, @@ -125,21 +126,17 @@ class ESPHomeDashboardUpdateEntity( (dr.CONNECTION_NETWORK_MAC, entry_data.device_info.mac_address) } ) + self._install_lock = asyncio.Lock() + self._available_future: asyncio.Future[None] | None = None self._update_attrs() @callback def _update_attrs(self) -> None: """Update the supported features.""" - # If the device has deep sleep, we can't assume we can install updates - # as the ESP will not be connectable (by design). coordinator = self.coordinator device_info = self._device_info # Install support can change at run time - if ( - coordinator.last_update_success - and coordinator.supports_update - and not device_info.has_deep_sleep - ): + if coordinator.last_update_success and coordinator.supports_update: self._attr_supported_features = UpdateEntityFeature.INSTALL else: self._attr_supported_features = NO_FEATURES @@ -178,6 +175,13 @@ class ESPHomeDashboardUpdateEntity( self, static_info: list[EntityInfo] | None = None ) -> None: """Handle updated data from the device.""" + if ( + self._entry_data.available + and self._available_future + and not self._available_future.done() + ): + self._available_future.set_result(None) + self._available_future = None self._update_attrs() self.async_write_ha_state() @@ -192,17 +196,46 @@ class ESPHomeDashboardUpdateEntity( entry_data.async_subscribe_device_updated(self._handle_device_update) ) + async def async_will_remove_from_hass(self) -> None: + """Handle entity about to be removed from Home Assistant.""" + if self._available_future and not self._available_future.done(): + self._available_future.cancel() + self._available_future = None + + async def _async_wait_available(self) -> None: + """Wait until the device is available.""" + # If the device has deep sleep, we need to wait for it to wake up + # and connect to the network to be able to install the update. + if self._entry_data.available: + return + self._available_future = self.hass.loop.create_future() + try: + await self._available_future + finally: + self._available_future = None + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): - coordinator = self.coordinator - api = coordinator.api - device = coordinator.data.get(self._device_info.name) - assert device is not None - configuration = device["configuration"] - try: + if self._install_lock.locked(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_in_progress", + translation_placeholders={ + "configuration": self._device_info.name, + }, + ) + + # Ensure only one OTA per device at a time + async with self._install_lock: + # Ensure only one compile at a time for ALL devices + async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): + coordinator = self.coordinator + api = coordinator.api + device = coordinator.data.get(self._device_info.name) + assert device is not None + configuration = device["configuration"] if not await api.compile(configuration): raise HomeAssistantError( translation_domain=DOMAIN, @@ -211,14 +244,25 @@ class ESPHomeDashboardUpdateEntity( "configuration": configuration, }, ) - if not await api.upload(configuration, "OTA"): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="error_uploading", - translation_placeholders={ - "configuration": configuration, - }, - ) + + # If the device uses deep sleep, there's a small chance it goes + # to sleep right after the dashboard connects but before the OTA + # starts. In that case, the update won't go through, so we try + # again to catch it on its next wakeup. + attempts = 2 if self._device_info.has_deep_sleep else 1 + try: + for attempt in range(1, attempts + 1): + await self._async_wait_available() + if await api.upload(configuration, "OTA"): + break + if attempt == attempts: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_uploading", + translation_placeholders={ + "configuration": configuration, + }, + ) finally: await self.coordinator.async_request_refresh() @@ -227,7 +271,9 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): """A update implementation for esphome.""" _attr_supported_features = ( - UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES ) @callback @@ -257,11 +303,12 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): """Return the latest version.""" return self._state.latest_version - @property - @esphome_state_property - def release_summary(self) -> str: - """Return the release summary.""" - return self._state.release_summary + @async_esphome_state_property + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + if self._state.release_summary: + return self._state.release_summary + return None @property @esphome_state_property diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 43a71458fb2..a93954b8a9b 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -2,8 +2,8 @@ import logging -from pyezviz.client import EzvizClient -from pyezviz.exceptions import ( +from pyezvizapi.client import EzvizClient +from pyezvizapi.exceptions import ( EzvizAuthTokenExpired, EzvizAuthVerificationCode, HTTPError, diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index 08fa0a68ee8..f945fcf3667 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -6,8 +6,8 @@ from dataclasses import dataclass from datetime import timedelta import logging -from pyezviz import PyEzvizError -from pyezviz.constants import DefenseModeType +from pyezvizapi import PyEzvizError +from pyezvizapi.constants import DefenseModeType from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 6dbb419c903..52e029dca98 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -6,9 +6,9 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from pyezviz import EzvizClient -from pyezviz.constants import SupportExt -from pyezviz.exceptions import HTTPError, PyEzvizError +from pyezvizapi import EzvizClient +from pyezvizapi.constants import SupportExt +from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index e3d01bef83e..a968543e5b7 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from pyezviz.exceptions import HTTPError, InvalidHost, PyEzvizError +from pyezvizapi.exceptions import HTTPError, InvalidHost, PyEzvizError from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera, CameraEntityFeature diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 845656c1d1d..622f767443d 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -6,15 +6,15 @@ from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any -from pyezviz.client import EzvizClient -from pyezviz.exceptions import ( +from pyezvizapi.client import EzvizClient +from pyezvizapi.exceptions import ( AuthTestResultFailed, EzvizAuthVerificationCode, InvalidHost, InvalidURL, PyEzvizError, ) -from pyezviz.test_cam_rtsp import TestRTSPAuth +from pyezvizapi.test_cam_rtsp import TestRTSPAuth import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index 0830784a501..c43e006ff96 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -4,8 +4,8 @@ import asyncio from datetime import timedelta import logging -from pyezviz.client import EzvizClient -from pyezviz.exceptions import ( +from pyezvizapi.client import EzvizClient +from pyezvizapi.exceptions import ( EzvizAuthTokenExpired, EzvizAuthVerificationCode, HTTPError, diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 28ebc7279e6..6ba1eec462c 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -5,8 +5,8 @@ from __future__ import annotations import logging from propcache.api import cached_property -from pyezviz.exceptions import PyEzvizError -from pyezviz.utils import decrypt_image +from pyezvizapi.exceptions import PyEzvizError +from pyezvizapi.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription from homeassistant.config_entries import SOURCE_IGNORE diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py index ba398dd3ed4..9c9382a4f3e 100644 --- a/homeassistant/components/ezviz/light.py +++ b/homeassistant/components/ezviz/light.py @@ -4,8 +4,8 @@ from __future__ import annotations from typing import Any -from pyezviz.constants import DeviceCatagories, DeviceSwitchType, SupportExt -from pyezviz.exceptions import HTTPError, PyEzvizError +from pyezvizapi.constants import DeviceCatagories, DeviceSwitchType, SupportExt +from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 53976bf3002..bef054eac27 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -1,11 +1,11 @@ { "domain": "ezviz", "name": "EZVIZ", - "codeowners": ["@RenierM26", "@baqs"], + "codeowners": ["@RenierM26"], "config_flow": true, "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", - "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.1.2"] + "loggers": ["paho_mqtt", "pyezvizapi"], + "requirements": ["pyezvizapi==1.0.0.7"] } diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 9bdd1feb81d..68a184d4972 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -6,8 +6,8 @@ from dataclasses import dataclass from datetime import timedelta import logging -from pyezviz.constants import SupportExt -from pyezviz.exceptions import ( +from pyezvizapi.constants import SupportExt +from pyezvizapi.exceptions import ( EzvizAuthTokenExpired, EzvizAuthVerificationCode, HTTPError, diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index 486564bff6e..44f80ad6cd1 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -4,8 +4,8 @@ from __future__ import annotations from dataclasses import dataclass -from pyezviz.constants import DeviceSwitchType, SoundMode -from pyezviz.exceptions import HTTPError, PyEzvizError +from pyezvizapi.constants import DeviceSwitchType, SoundMode +from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py index a2c88f58972..1cbc17ba464 100644 --- a/homeassistant/components/ezviz/siren.py +++ b/homeassistant/components/ezviz/siren.py @@ -6,7 +6,7 @@ from collections.abc import Callable from datetime import datetime, timedelta from typing import Any -from pyezviz import HTTPError, PyEzvizError, SupportExt +from pyezvizapi import HTTPError, PyEzvizError, SupportExt from homeassistant.components.siren import ( SirenEntity, diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 01f7cac1a55..ae8419367c4 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -5,8 +5,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from pyezviz.constants import DeviceSwitchType, SupportExt -from pyezviz.exceptions import HTTPError, PyEzvizError +from pyezvizapi.constants import DeviceSwitchType, SupportExt +from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.switch import ( SwitchDeviceClass, diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index c9f8038b336..ffd9a260ce9 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from pyezviz import HTTPError, PyEzvizError +from pyezvizapi import HTTPError, PyEzvizError from homeassistant.components.update import ( UpdateDeviceClass, diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py index 578b5b1e175..dc7c9e880d5 100644 --- a/homeassistant/components/feedreader/event.py +++ b/homeassistant/components/feedreader/event.py @@ -61,15 +61,9 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity): entry_type=DeviceEntryType.SERVICE, ) - async def async_added_to_hass(self) -> None: - """Entity added to hass.""" - await super().async_added_to_hass() - self.async_on_remove( - self.coordinator.async_add_listener(self._async_handle_update) - ) - @callback - def _async_handle_update(self) -> None: + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" if (data := self.coordinator.data) is None or not data: return diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json index bd8f23602e3..02f8c42755b 100644 --- a/homeassistant/components/file/strings.json +++ b/homeassistant/components/file/strings.json @@ -4,13 +4,13 @@ "user": { "description": "Make a choice", "menu_options": { - "sensor": "Set up a file based sensor", + "sensor": "Set up a file-based sensor", "notify": "Set up a notification service" } }, "sensor": { "title": "File sensor", - "description": "Set up a file based sensor", + "description": "[%key:component::file::config::step::user::menu_options::sensor%]", "data": { "file_path": "File path", "value_template": "Value template", diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py index dac2d8995bf..7bbfb9f6f0a 100644 --- a/homeassistant/components/filter/config_flow.py +++ b/homeassistant/components/filter/config_flow.py @@ -105,9 +105,18 @@ DATA_SCHEMA_SETUP = vol.Schema( ) BASE_OPTIONS_SCHEMA = { + vol.Optional(CONF_ENTITY_ID): EntitySelector(EntitySelectorConfig(read_only=True)), + vol.Optional(CONF_FILTER_NAME): SelectSelector( + SelectSelectorConfig( + options=FILTERS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_FILTER_NAME, + read_only=True, + ) + ), vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector( NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ) + ), } OUTLIER_SCHEMA = vol.Schema( diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index b0403227fd4..faa1de8b9df 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -23,12 +23,16 @@ "data": { "window_size": "Window size", "precision": "Precision", - "radius": "Radius" + "radius": "Radius", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "Size of the window of previous states.", "precision": "Defines the number of decimal places of the calculated sensor value.", - "radius": "Band radius from median of previous states." + "radius": "Band radius from median of previous states.", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "lowpass": { @@ -36,12 +40,16 @@ "data": { "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data::precision%]", - "time_constant": "Time constant" + "time_constant": "Time constant", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", - "time_constant": "Loosely relates to the amount of time it takes for a state to influence the output." + "time_constant": "Loosely relates to the amount of time it takes for a state to influence the output.", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "range": { @@ -49,12 +57,16 @@ "data": { "precision": "[%key:component::filter::config::step::outlier::data::precision%]", "lower_bound": "Lower bound", - "upper_bound": "Upper bound" + "upper_bound": "Upper bound", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", "lower_bound": "Lower bound for filter range.", - "upper_bound": "Upper bound for filter range." + "upper_bound": "Upper bound for filter range.", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "time_simple_moving_average": { @@ -62,34 +74,46 @@ "data": { "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data::precision%]", - "type": "Type" + "type": "Type", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", - "type": "Defines the type of Simple Moving Average." + "type": "Defines the type of Simple Moving Average.", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "throttle": { "description": "[%key:component::filter::config::step::outlier::description%]", "data": { "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", - "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", - "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "time_throttle": { "description": "[%key:component::filter::config::step::outlier::description%]", "data": { "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", - "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", - "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } } } @@ -104,12 +128,16 @@ "data": { "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data::precision%]", - "radius": "[%key:component::filter::config::step::outlier::data::radius%]" + "radius": "[%key:component::filter::config::step::outlier::data::radius%]", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", - "radius": "[%key:component::filter::config::step::outlier::data_description::radius%]" + "radius": "[%key:component::filter::config::step::outlier::data_description::radius%]", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "lowpass": { @@ -117,12 +145,16 @@ "data": { "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data::precision%]", - "time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]" + "time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", - "time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]" + "time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "range": { @@ -130,12 +162,16 @@ "data": { "precision": "[%key:component::filter::config::step::outlier::data::precision%]", "lower_bound": "[%key:component::filter::config::step::range::data::lower_bound%]", - "upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]" + "upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", "lower_bound": "[%key:component::filter::config::step::range::data_description::lower_bound%]", - "upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]" + "upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "time_simple_moving_average": { @@ -143,34 +179,46 @@ "data": { "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data::precision%]", - "type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]" + "type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", - "type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]" + "type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "throttle": { "description": "[%key:component::filter::config::step::outlier::description%]", "data": { "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", - "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", - "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } }, "time_throttle": { "description": "[%key:component::filter::config::step::outlier::description%]", "data": { "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", - "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "entity_id": "[%key:component::filter::config::step::user::data::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data::filter%]" }, "data_description": { "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", - "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]", + "filter": "[%key:component::filter::config::step::user::data_description::filter%]" } } } @@ -183,7 +231,7 @@ "outlier": "Outlier", "throttle": "Throttle", "time_throttle": "Time throttle", - "time_simple_moving_average": "Moving Average (Time based)" + "time_simple_moving_average": "Moving average (time-based)" } }, "type": { diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 5ed65609dc8..f7414d7e1bd 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -9,7 +9,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN as FIRESERVICEROTA_DOMAIN +from .const import DOMAIN from .coordinator import FireServiceConfigEntry, FireServiceRotaClient _LOGGER = logging.getLogger(__name__) @@ -106,7 +106,7 @@ class IncidentsSensor(RestoreEntity, SensorEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{FIRESERVICEROTA_DOMAIN}_{self._entry_id}_update", + f"{DOMAIN}_{self._entry_id}_update", self.client_update, ) ) diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py index d9fe382e4b1..26dc3b27c19 100644 --- a/homeassistant/components/fireservicerota/switch.py +++ b/homeassistant/components/fireservicerota/switch.py @@ -9,7 +9,7 @@ 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 FIRESERVICEROTA_DOMAIN +from .const import DOMAIN from .coordinator import ( FireServiceConfigEntry, FireServiceRotaClient, @@ -122,7 +122,7 @@ class ResponseSwitch(SwitchEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{FIRESERVICEROTA_DOMAIN}_{self._entry_id}_update", + f"{DOMAIN}_{self._entry_id}_update", self.client_update, ) ) diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index 9f540b230f4..0e50c8c6b03 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN as FLO_DOMAIN, LOGGER +from .const import DOMAIN, LOGGER type FloConfigEntry = ConfigEntry[FloRuntimeData] @@ -55,7 +55,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): hass, LOGGER, config_entry=config_entry, - name=f"{FLO_DOMAIN}-{device_id}", + name=f"{DOMAIN}-{device_id}", update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index 072afbae4f2..c9717b16059 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DOMAIN as FLO_DOMAIN +from .const import DOMAIN from .coordinator import FloDeviceDataUpdateCoordinator @@ -32,7 +32,7 @@ class FloEntity(Entity): """Return a device description for device registry.""" return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, self._device.mac_address)}, - identifiers={(FLO_DOMAIN, self._device.id)}, + identifiers={(DOMAIN, self._device.id)}, serial_number=self._device.serial_number, manufacturer=self._device.manufacturer, model=self._device.model, diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index 201a3cd415c..278e68db9a1 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -54,13 +54,13 @@ "name": "Estimated power production - now" }, "power_production_next_hour": { - "name": "Estimated power production - next hour" + "name": "Estimated power production - in 1 hour" }, "power_production_next_12hours": { - "name": "Estimated power production - next 12 hours" + "name": "Estimated power production - in 12 hours" }, "power_production_next_24hours": { - "name": "Estimated power production - next 24 hours" + "name": "Estimated power production - in 24 hours" }, "energy_current_hour": { "name": "Estimated energy production - this hour" diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index 90ebd53048a..94ccae61088 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -1,25 +1,21 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" from datetime import timedelta -import logging from freebox_api.exceptions import HttpRequestError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_track_time_interval -from .const import DOMAIN, PLATFORMS, SERVICE_REBOOT -from .router import FreeboxRouter, get_api +from .const import PLATFORMS +from .router import FreeboxConfigEntry, FreeboxRouter, get_api SCAN_INTERVAL = timedelta(seconds=30) -_LOGGER = logging.getLogger(__name__) - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool: """Set up Freebox entry.""" api = await get_api(hass, entry.data[CONF_HOST]) try: @@ -35,25 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_track_time_interval(hass, router.update_all, SCAN_INTERVAL) ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.unique_id] = router + entry.runtime_data = router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Services - async def async_reboot(call: ServiceCall) -> None: - """Handle reboot service call.""" - # The Freebox reboot service has been replaced by a - # dedicated button entity and marked as deprecated - _LOGGER.warning( - "The 'freebox.reboot' service is deprecated and " - "replaced by a dedicated reboot button entity; please " - "use that entity to reboot the freebox instead" - ) - await router.reboot() - - hass.services.async_register(DOMAIN, SERVICE_REBOOT, async_reboot) - async def async_close_connection(event: Event) -> None: """Close Freebox connection on HA Stop.""" await router.close() @@ -61,16 +42,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection) ) + entry.async_on_unload(router.close) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - router: FreeboxRouter = hass.data[DOMAIN].pop(entry.unique_id) - await router.close() - hass.services.async_remove(DOMAIN, SERVICE_REBOOT) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py index 89462b33a2f..b0242a1b054 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -7,13 +7,12 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, FreeboxHomeCategory +from .const import FreeboxHomeCategory from .entity import FreeboxHomeEntity -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter FREEBOX_TO_STATUS = { "alarm1_arming": AlarmControlPanelState.ARMING, @@ -29,11 +28,11 @@ FREEBOX_TO_STATUS = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up alarm panel.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 9fc9929b869..75b7dded36a 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -10,15 +10,14 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory 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, FreeboxHomeCategory +from .const import FreeboxHomeCategory from .entity import FreeboxHomeEntity -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -35,11 +34,11 @@ RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data _LOGGER.debug("%s - %s - %s raid(s)", router.name, router.mac, len(router.raids)) diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index 4f676fd46a1..21a7b1c9990 100644 --- a/homeassistant/components/freebox/button.py +++ b/homeassistant/components/freebox/button.py @@ -10,13 +10,11 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -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 .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter @dataclass(frozen=True, kw_only=True) @@ -45,11 +43,11 @@ BUTTON_DESCRIPTIONS: tuple[FreeboxButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the buttons.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data entities = [ FreeboxButton(router, description) for description in BUTTON_DESCRIPTIONS ] diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index 45bb5a34063..d997908dd06 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -12,27 +12,26 @@ from homeassistant.components.ffmpeg.camera import ( DEFAULT_ARGUMENTS, FFmpegCamera, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_DETECTION, DOMAIN, FreeboxHomeCategory +from .const import ATTR_DETECTION, FreeboxHomeCategory from .entity import FreeboxHomeEntity -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cameras.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data tracked: set[str] = set() @callback diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 13be45926b4..da5ae836be0 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -8,7 +8,6 @@ import socket from homeassistant.const import Platform DOMAIN = "freebox" -SERVICE_REBOOT = "reboot" APP_DESC = { "app_id": "hass", diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index dcb6eb104b2..243f0de315a 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -6,22 +6,21 @@ from datetime import datetime from typing import Any from homeassistant.components.device_tracker import ScannerEntity -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 DEFAULT_DEVICE_NAME, DEVICE_ICONS, DOMAIN -from .router import FreeboxRouter +from .const import DEFAULT_DEVICE_NAME, DEVICE_ICONS +from .router import FreeboxConfigEntry, FreeboxRouter async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Freebox component.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data tracked: set[str] = set() @callback diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 753bdff8cec..d6c45cd178b 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -38,6 +38,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type FreeboxConfigEntry = ConfigEntry[FreeboxRouter] + def is_json(json_str: str) -> bool: """Validate if a String is a JSON value or not.""" @@ -102,7 +104,7 @@ class FreeboxRouter: def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, api: Freepybox, freebox_config: Mapping[str, Any], ) -> None: diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index cc62de9ae0d..45fe18db95a 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -9,8 +9,8 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfDataRate, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -20,7 +20,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN from .entity import FreeboxHomeEntity -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -29,6 +29,7 @@ CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = ( key="rate_down", name="Freebox download speed", device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, icon="mdi:download-network", ), @@ -36,6 +37,7 @@ CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = ( key="rate_up", name="Freebox upload speed", device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, icon="mdi:upload-network", ), @@ -61,11 +63,11 @@ DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data entities: list[SensorEntity] = [] _LOGGER.debug( @@ -82,6 +84,7 @@ async def async_setup_entry( name=f"Freebox {sensor_name}", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), ) for sensor_name in router.sensors_temperature diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index c4618b014bf..9506a87b5fa 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -8,13 +8,11 @@ from typing import Any from freebox_api.exceptions import InsufficientPermissionsError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -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 .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -30,11 +28,11 @@ SWITCH_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data entities = [ FreeboxSwitch(router, entity_description) for entity_description in SWITCH_DESCRIPTIONS diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 05a2a07707f..9610fe4b34d 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -15,6 +15,8 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_FEATURE_DEVICE_TRACKING, + DEFAULT_CONF_FEATURE_DEVICE_TRACKING, DEFAULT_SSL, DOMAIN, FRITZ_AUTH_EXCEPTIONS, @@ -38,6 +40,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool: """Set up fritzboxtools from config entry.""" _LOGGER.debug("Setting up FRITZ!Box Tools component") + avm_wrapper = AvmWrapper( hass=hass, config_entry=entry, @@ -46,6 +49,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], use_tls=entry.data.get(CONF_SSL, DEFAULT_SSL), + device_discovery_enabled=entry.options.get( + CONF_FEATURE_DEVICE_TRACKING, DEFAULT_CONF_FEATURE_DEVICE_TRACKING + ), ) try: @@ -62,6 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo raise ConfigEntryAuthFailed("Missing UPnP configuration") await avm_wrapper.async_config_entry_first_refresh() + await avm_wrapper.async_trigger_cleanup() entry.runtime_data = avm_wrapper diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index fb17f872cb6..2c22a35c4dd 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -35,7 +35,9 @@ from homeassistant.helpers.service_info.ssdp import ( from homeassistant.helpers.typing import VolDictType from .const import ( + CONF_FEATURE_DEVICE_TRACKING, CONF_OLD_DISCOVERY, + DEFAULT_CONF_FEATURE_DEVICE_TRACKING, DEFAULT_CONF_OLD_DISCOVERY, DEFAULT_HOST, DEFAULT_HTTP_PORT, @@ -72,7 +74,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize FRITZ!Box Tools flow.""" self._name: str = "" self._password: str = "" - self._use_tls: bool = False + self._use_tls: bool = DEFAULT_SSL + self._feature_device_discovery: bool = DEFAULT_CONF_FEATURE_DEVICE_TRACKING self._port: int | None = None self._username: str = "" self._model: str = "" @@ -141,6 +144,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): options={ CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME.total_seconds(), CONF_OLD_DISCOVERY: DEFAULT_CONF_OLD_DISCOVERY, + CONF_FEATURE_DEVICE_TRACKING: self._feature_device_discovery, }, ) @@ -204,6 +208,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] self._use_tls = user_input[CONF_SSL] + self._feature_device_discovery = user_input[CONF_FEATURE_DEVICE_TRACKING] self._port = self._determine_port(user_input) error = await self.async_fritz_tools_init() @@ -234,6 +239,10 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Required( + CONF_FEATURE_DEVICE_TRACKING, + default=DEFAULT_CONF_FEATURE_DEVICE_TRACKING, + ): bool, } ), errors=errors or {}, @@ -250,6 +259,10 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Required( + CONF_FEATURE_DEVICE_TRACKING, + default=DEFAULT_CONF_FEATURE_DEVICE_TRACKING, + ): bool, } ), description_placeholders={"name": self._name}, @@ -405,7 +418,7 @@ class FritzBoxToolsOptionsFlowHandler(OptionsFlow): """Handle options flow.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(data=user_input) options = self.config_entry.options data_schema = vol.Schema( @@ -420,6 +433,13 @@ class FritzBoxToolsOptionsFlowHandler(OptionsFlow): CONF_OLD_DISCOVERY, default=options.get(CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY), ): bool, + vol.Optional( + CONF_FEATURE_DEVICE_TRACKING, + default=options.get( + CONF_FEATURE_DEVICE_TRACKING, + DEFAULT_CONF_FEATURE_DEVICE_TRACKING, + ), + ): bool, } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 2237823bc3b..32f52e68458 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -40,6 +40,9 @@ PLATFORMS = [ CONF_OLD_DISCOVERY = "old_discovery" DEFAULT_CONF_OLD_DISCOVERY = False +CONF_FEATURE_DEVICE_TRACKING = "feature_device_tracking" +DEFAULT_CONF_FEATURE_DEVICE_TRACKING = True + DSL_CONNECTION: Literal["dsl"] = "dsl" DEFAULT_DEVICE_NAME = "Unknown device" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 9199692f564..e22a66d254f 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -39,6 +39,7 @@ from homeassistant.util.hass_dict import HassKey from .const import ( CONF_OLD_DISCOVERY, + DEFAULT_CONF_FEATURE_DEVICE_TRACKING, DEFAULT_CONF_OLD_DISCOVERY, DEFAULT_HOST, DEFAULT_SSL, @@ -175,6 +176,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): username: str = DEFAULT_USERNAME, host: str = DEFAULT_HOST, use_tls: bool = DEFAULT_SSL, + device_discovery_enabled: bool = DEFAULT_CONF_FEATURE_DEVICE_TRACKING, ) -> None: """Initialize FritzboxTools class.""" super().__init__( @@ -202,6 +204,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.port = port self.username = username self.use_tls = use_tls + self.device_discovery_enabled = device_discovery_enabled self.has_call_deflections: bool = False self._model: str | None = None self._current_firmware: str | None = None @@ -332,10 +335,15 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): "entity_states": {}, } try: - await self.async_scan_devices() + await self.async_update_device_info() + + if self.device_discovery_enabled: + await self.async_scan_devices() + entity_data["entity_states"] = await self.hass.async_add_executor_job( self._entity_states_update ) + if self.has_call_deflections: entity_data[ "call_deflections" @@ -521,7 +529,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): return {} def manage_device_info( - self, dev_info: Device, dev_mac: str, consider_home: bool + self, dev_info: Device, dev_mac: str, consider_home: float ) -> bool: """Update device lists and return if device is new.""" _LOGGER.debug("Client dev_info: %s", dev_info) @@ -551,12 +559,8 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): if new_device: async_dispatcher_send(self.hass, self.signal_device_new) - async def async_scan_devices(self, now: datetime | None = None) -> None: - """Scan for new devices and return a list of found device ids.""" - - if self.hass.is_stopping: - _ha_is_stopping("scan devices") - return + async def async_update_device_info(self, now: datetime | None = None) -> None: + """Update own device information.""" _LOGGER.debug("Checking host info for FRITZ!Box device %s", self.host) ( @@ -565,6 +569,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self._release_url, ) = await self._async_update_device_info() + async def async_scan_devices(self, now: datetime | None = None) -> None: + """Scan for new network devices.""" + + if self.hass.is_stopping: + _ha_is_stopping("scan devices") + return + _LOGGER.debug("Checking devices for FRITZ!Box device %s", self.host) _default_consider_home = DEFAULT_CONSIDER_HOME.total_seconds() if self._options: @@ -683,7 +694,10 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): async def async_trigger_cleanup(self) -> None: """Trigger device trackers cleanup.""" - device_hosts = await self._async_update_hosts_info() + _LOGGER.debug("Device tracker cleanup triggered") + device_hosts = {self.mac: Device(True, "", "", "", "", None)} + if self.device_discovery_enabled: + device_hosts = await self._async_update_hosts_info() entity_reg: er.EntityRegistry = er.async_get(self.hass) config_entry = self.config_entry diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 6191fc524dd..ee23a8cfbef 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -4,7 +4,9 @@ "data_description_port": "Leave empty to use the default port.", "data_description_username": "Username for the FRITZ!Box.", "data_description_password": "Password for the FRITZ!Box.", - "data_description_ssl": "Use SSL to connect to the FRITZ!Box." + "data_description_ssl": "Use SSL to connect to the FRITZ!Box.", + "data_description_feature_device_tracking": "Enable or disable the network device tracking feature.", + "data_feature_device_tracking": "Enable network device tracking" }, "config": { "flow_title": "{name}", @@ -15,12 +17,14 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]" + "ssl": "[%key:common::config_flow::data::ssl%]", + "feature_device_tracking": "[%key:component::fritz::common::data_feature_device_tracking%]" }, "data_description": { "username": "[%key:component::fritz::common::data_description_username%]", "password": "[%key:component::fritz::common::data_description_password%]", - "ssl": "[%key:component::fritz::common::data_description_ssl%]" + "ssl": "[%key:component::fritz::common::data_description_ssl%]", + "feature_device_tracking": "[%key:component::fritz::common::data_description_feature_device_tracking%]" } }, "reauth_confirm": { @@ -57,14 +61,16 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]" + "ssl": "[%key:common::config_flow::data::ssl%]", + "feature_device_tracking": "[%key:component::fritz::common::data_feature_device_tracking%]" }, "data_description": { "host": "[%key:component::fritz::common::data_description_host%]", "port": "[%key:component::fritz::common::data_description_port%]", "username": "[%key:component::fritz::common::data_description_username%]", "password": "[%key:component::fritz::common::data_description_password%]", - "ssl": "[%key:component::fritz::common::data_description_ssl%]" + "ssl": "[%key:component::fritz::common::data_description_ssl%]", + "feature_device_tracking": "[%key:component::fritz::common::data_description_feature_device_tracking%]" } } }, @@ -89,11 +95,13 @@ "init": { "data": { "consider_home": "Seconds to consider a device at 'home'", - "old_discovery": "Enable old discovery method" + "old_discovery": "Enable old discovery method", + "feature_device_tracking": "[%key:component::fritz::common::data_feature_device_tracking%]" }, "data_description": { "consider_home": "Time in seconds to consider a device at home. Default is 180 seconds.", - "old_discovery": "Enable old discovery method. This is needed for some scenarios." + "old_discovery": "Enable old discovery method. This is needed for some scenarios.", + "feature_device_tracking": "[%key:component::fritz::common::data_description_feature_device_tracking%]" } } } diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 573877fa71b..ec4b09a2af2 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -111,11 +111,9 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): """Write the state to the HASS state machine.""" if self.data.holiday_active: self._attr_supported_features = ClimateEntityFeature.PRESET_MODE - self._attr_hvac_modes = [HVACMode.HEAT] self._attr_preset_modes = [PRESET_HOLIDAY] elif self.data.summer_active: self._attr_supported_features = ClimateEntityFeature.PRESET_MODE - self._attr_hvac_modes = [HVACMode.OFF] self._attr_preset_modes = [PRESET_SUMMER] else: self._attr_supported_features = SUPPORTED_FEATURES diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 437b218a8e2..35af748ebe7 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -39,9 +39,9 @@ "options": { "step": { "init": { - "title": "Configure Prefixes", + "title": "Configure prefixes", "data": { - "prefixes": "Prefixes (comma separated list)" + "prefixes": "Prefixes (comma-separated list)" } } }, diff --git a/homeassistant/components/fronius/icons.json b/homeassistant/components/fronius/icons.json index a84140617dd..59d5a110449 100644 --- a/homeassistant/components/fronius/icons.json +++ b/homeassistant/components/fronius/icons.json @@ -4,13 +4,13 @@ "current_dc": { "default": "mdi:current-dc" }, - "current_dc_2": { + "current_dc_mppt_no": { "default": "mdi:current-dc" }, "voltage_dc": { "default": "mdi:current-dc" }, - "voltage_dc_2": { + "voltage_dc_mppt_no": { "default": "mdi:current-dc" }, "co2_factor": { diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 661d808ad23..3928860711a 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["pyfronius"], "quality_scale": "platinum", - "requirements": ["PyFronius==0.7.7"] + "requirements": ["PyFronius==0.8.0"] } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index c65f6072ba6..e287786aaa8 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -168,6 +168,26 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + translation_key="current_dc_mppt_no", + translation_placeholders={"mppt_no": "2"}, + ), + FroniusSensorEntityDescription( + key="current_dc_3", + default_value=0, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="current_dc_mppt_no", + translation_placeholders={"mppt_no": "3"}, + ), + FroniusSensorEntityDescription( + key="current_dc_4", + default_value=0, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="current_dc_mppt_no", + translation_placeholders={"mppt_no": "4"}, ), FroniusSensorEntityDescription( key="power_ac", @@ -197,6 +217,26 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_dc_mppt_no", + translation_placeholders={"mppt_no": "2"}, + ), + FroniusSensorEntityDescription( + key="voltage_dc_3", + default_value=0, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_dc_mppt_no", + translation_placeholders={"mppt_no": "3"}, + ), + FroniusSensorEntityDescription( + key="voltage_dc_4", + default_value=0, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_dc_mppt_no", + translation_placeholders={"mppt_no": "4"}, ), # device status entities FroniusSensorEntityDescription( @@ -727,7 +767,7 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn self.response_key = description.response_key or description.key self.solar_net_id = solar_net_id self._attr_native_value = self._get_entity_value() - self._attr_translation_key = description.key + self._attr_translation_key = description.translation_key or description.key def _device_data(self) -> dict[str, Any]: """Extract information for SolarNet device from coordinator data.""" diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 7c42cca29de..e965e3117c5 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -52,8 +52,8 @@ "current_dc": { "name": "DC current" }, - "current_dc_2": { - "name": "DC current 2" + "current_dc_mppt_no": { + "name": "DC current {mppt_no}" }, "power_ac": { "name": "AC power" @@ -64,8 +64,8 @@ "voltage_dc": { "name": "DC voltage" }, - "voltage_dc_2": { - "name": "DC voltage 2" + "voltage_dc_mppt_no": { + "name": "DC voltage {mppt_no}" }, "inverter_state": { "name": "Inverter state" @@ -107,7 +107,7 @@ "ac_module_temperature_sensor_faulty_l2": "AC module temperature sensor faulty (L2)", "dc_component_measured_in_grid_too_high": "DC component measured in the grid too high", "fixed_voltage_mode_out_of_range": "Fixed voltage mode has been selected instead of MPP voltage mode and the fixed voltage has been set to too low or too high a value", - "safety_cut_out_triggered": "Safety cut out via option card or RECERBO has triggered", + "safety_cut_out_triggered": "Safety cut-out via option card or RECERBO has triggered", "no_communication_between_power_stage_and_control_system": "No communication possible between power stage set and control system", "hardware_id_problem": "Hardware ID problem", "unique_id_conflict": "Unique ID conflict", @@ -140,16 +140,16 @@ "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 – Overcurrent on USB stick", + "eeprom_reinitialised": "EEPROM has been re-initialized", + "initialisation_error_usb_flash_drive_not_supported": "Initialization error – USB flash drive is not supported", + "initialisation_error_usb_stick_over_current": "Initialization 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_not_recognised_or_missing": "Update file not recognized or not present", "update_file_does_not_match_device": "Update file does not match the device, update file too old", "write_or_read_error_occurred": "Write or read error occurred", "file_could_not_be_opened": "File could not be opened", - "log_file_cannot_be_saved": "Log file cannot be saved (e.g. USB flash drive is write protected or full)", - "initialisation_error_file_system_error_on_usb": "Initialisation error in file system on USB flash drive", + "log_file_cannot_be_saved": "Log file cannot be saved (e.g. USB flash drive is write-protected or full)", + "initialisation_error_file_system_error_on_usb": "Initialization error in file system on USB flash drive", "error_during_logging_data_recording": "Error during recording of logging data", "error_during_update_process": "Error occurred during update process", "update_file_corrupt": "Update file corrupt", @@ -166,7 +166,7 @@ "invalid_device_type": "Invalid device type", "insulation_measurement_triggered": "Insulation measurement triggered", "inverter_settings_changed_restart_required": "Inverter settings have been changed, inverter restart required", - "wired_shut_down_triggered": "Wired shut down triggered", + "wired_shut_down_triggered": "Wired shutdown triggered", "grid_frequency_exceeded_limit_reconnecting": "The grid frequency has exceeded a limit value when reconnecting", "mains_voltage_dependent_power_reduction": "Mains voltage-dependent power reduction", "too_little_dc_power_for_feed_in_operation": "Too little DC power for feed-in operation", diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 5c5feca98b7..4299d2b7503 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==20250516.0"] + "requirements": ["home-assistant-frontend==20250531.2"] } diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index a33a9de7ac5..11d155dbcb4 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -14,49 +14,78 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.util.hass_dict import HassKey -DATA_STORAGE: HassKey[tuple[dict[str, Store], dict[str, dict]]] = HassKey( - "frontend_storage" -) +DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage") STORAGE_VERSION_USER_DATA = 1 -@callback -def _initialize_frontend_storage(hass: HomeAssistant) -> None: - """Set up frontend storage.""" - if DATA_STORAGE in hass.data: - return - hass.data[DATA_STORAGE] = ({}, {}) - - async def async_setup_frontend_storage(hass: HomeAssistant) -> None: """Set up frontend storage.""" - _initialize_frontend_storage(hass) websocket_api.async_register_command(hass, websocket_set_user_data) websocket_api.async_register_command(hass, websocket_get_user_data) + websocket_api.async_register_command(hass, websocket_subscribe_user_data) -async def async_user_store( - hass: HomeAssistant, user_id: str -) -> tuple[Store, dict[str, Any]]: +async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore: """Access a user store.""" - _initialize_frontend_storage(hass) - stores, data = hass.data[DATA_STORAGE] + stores = hass.data.setdefault(DATA_STORAGE, {}) if (store := stores.get(user_id)) is None: - store = stores[user_id] = Store( + store = stores[user_id] = UserStore(hass, user_id) + await store.async_load() + + return store + + +class UserStore: + """User store for frontend data.""" + + def __init__(self, hass: HomeAssistant, user_id: str) -> None: + """Initialize the user store.""" + self._store = _UserStore(hass, user_id) + self.data: dict[str, Any] = {} + self.subscriptions: dict[str | None, list[Callable[[], None]]] = {} + + async def async_load(self) -> None: + """Load the data from the store.""" + self.data = await self._store.async_load() or {} + + async def async_set_item(self, key: str, value: Any) -> None: + """Set an item item and save the store.""" + self.data[key] = value + await self._store.async_save(self.data) + for cb in self.subscriptions.get(None, []): + cb() + for cb in self.subscriptions.get(key, []): + cb() + + @callback + def async_subscribe( + self, key: str | None, on_update_callback: Callable[[], None] + ) -> Callable[[], None]: + """Save the data to the store.""" + self.subscriptions.setdefault(key, []).append(on_update_callback) + + def unsubscribe() -> None: + """Unsubscribe from the store.""" + self.subscriptions[key].remove(on_update_callback) + + return unsubscribe + + +class _UserStore(Store[dict[str, Any]]): + """User store for frontend data.""" + + def __init__(self, hass: HomeAssistant, user_id: str) -> None: + """Initialize the user store.""" + super().__init__( hass, STORAGE_VERSION_USER_DATA, f"frontend.user_data_{user_id}", ) - if user_id not in data: - data[user_id] = await store.async_load() or {} - return store, data[user_id] - - -def with_store( +def with_user_store( orig_func: Callable[ - [HomeAssistant, ActiveConnection, dict[str, Any], Store, dict[str, Any]], + [HomeAssistant, ActiveConnection, dict[str, Any], UserStore], Coroutine[Any, Any, None], ], ) -> Callable[ @@ -65,17 +94,17 @@ def with_store( """Decorate function to provide data.""" @wraps(orig_func) - async def with_store_func( + async def with_user_store_func( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" user_id = connection.user.id - store, user_data = await async_user_store(hass, user_id) + store = await async_user_store(hass, user_id) - await orig_func(hass, connection, msg, store, user_data) + await orig_func(hass, connection, msg, store) - return with_store_func + return with_user_store_func @websocket_api.websocket_command( @@ -86,41 +115,57 @@ def with_store( } ) @websocket_api.async_response -@with_store +@with_user_store async def websocket_set_user_data( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - store: Store, - data: dict[str, Any], + store: UserStore, ) -> None: - """Handle set global data command. - - Async friendly. - """ - data[msg["key"]] = msg["value"] - await store.async_save(data) - connection.send_message(websocket_api.result_message(msg["id"])) + """Handle set user data command.""" + await store.async_set_item(msg["key"], msg["value"]) + connection.send_result(msg["id"]) @websocket_api.websocket_command( {vol.Required("type"): "frontend/get_user_data", vol.Optional("key"): str} ) @websocket_api.async_response -@with_store +@with_user_store async def websocket_get_user_data( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - store: Store, - data: dict[str, Any], + store: UserStore, ) -> None: - """Handle get global data command. - - Async friendly. - """ - connection.send_message( - websocket_api.result_message( - msg["id"], {"value": data.get(msg["key"]) if "key" in msg else data} - ) + """Handle get user data command.""" + data = store.data + connection.send_result( + msg["id"], {"value": data.get(msg["key"]) if "key" in msg else data} ) + + +@websocket_api.websocket_command( + {vol.Required("type"): "frontend/subscribe_user_data", vol.Optional("key"): str} +) +@websocket_api.async_response +@with_user_store +async def websocket_subscribe_user_data( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + store: UserStore, +) -> None: + """Handle subscribe to user data command.""" + key: str | None = msg.get("key") + + def on_data_update() -> None: + """Handle user data update.""" + data = store.data + connection.send_event( + msg["id"], {"value": data.get(key) if key is not None else data} + ) + + connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update) + on_data_update() + connection.send_result(msg["id"]) diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 1b00afc9c80..2264f341bad 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -84,7 +84,10 @@ async def async_migrate_entry( new[CONF_EXPIRATION] = credentials.expiration.isoformat() hass.config_entries.async_update_entry( - config_entry, data=new, minor_version=2, version=1 + config_entry, + data=new, + minor_version=2, + version=1, ) _LOGGER.debug( diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py index 326f2ddf570..891c0bf53eb 100644 --- a/homeassistant/components/fyta/image.py +++ b/homeassistant/components/fyta/image.py @@ -2,9 +2,20 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime +import logging +from typing import Final -from homeassistant.components.image import ImageEntity, ImageEntityDescription +from fyta_cli.fyta_models import Plant + +from homeassistant.components.image import ( + Image, + ImageEntity, + ImageEntityDescription, + valid_image_content_type, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -12,6 +23,30 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class FytaImageEntityDescription(ImageEntityDescription): + """Describes Fyta image entity.""" + + url_fn: Callable[[Plant], str] + name_key: str | None = None + + +IMAGES: Final[list[FytaImageEntityDescription]] = [ + FytaImageEntityDescription( + key="plant_image", + translation_key="plant_image", + url_fn=lambda plant: plant.plant_origin_path, + ), + FytaImageEntityDescription( + key="plant_image_user", + translation_key="plant_image_user", + url_fn=lambda plant: plant.user_picture_path, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -21,17 +56,17 @@ async def async_setup_entry( """Set up the FYTA plant images.""" coordinator = entry.runtime_data - description = ImageEntityDescription(key="plant_image") - async_add_entities( FytaPlantImageEntity(coordinator, entry, description, plant_id) for plant_id in coordinator.fyta.plant_list if plant_id in coordinator.data + for description in IMAGES ) def _async_add_new_device(plant_id: int) -> None: async_add_entities( - [FytaPlantImageEntity(coordinator, entry, description, plant_id)] + FytaPlantImageEntity(coordinator, entry, description, plant_id) + for description in IMAGES ) coordinator.new_device_callbacks.append(_async_add_new_device) @@ -40,26 +75,49 @@ async def async_setup_entry( class FytaPlantImageEntity(FytaPlantEntity, ImageEntity): """Represents a Fyta image.""" - entity_description: ImageEntityDescription + entity_description: FytaImageEntityDescription def __init__( self, coordinator: FytaCoordinator, entry: ConfigEntry, - description: ImageEntityDescription, + description: FytaImageEntityDescription, plant_id: int, ) -> None: - """Initiatlize Fyta Image entity.""" + """Initialize Fyta Image entity.""" super().__init__(coordinator, entry, description, plant_id) ImageEntity.__init__(self, coordinator.hass) - self._attr_name = None + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + if self.entity_description.key == "plant_image_user": + if self._cached_image is None: + response = await self.coordinator.fyta.get_plant_image( + self.plant.user_picture_path + ) + _LOGGER.debug("Response of downloading user image: %s", response) + if response is None: + _LOGGER.debug( + "%s: Error getting new image from %s", + self.entity_id, + self.plant.user_picture_path, + ) + return None + + content_type, raw_image = response + self._cached_image = Image( + valid_image_content_type(content_type), raw_image + ) + + return self._cached_image.content + return await ImageEntity.async_image(self) @property def image_url(self) -> str: - """Return the image_url for this sensor.""" - image = self.plant.plant_origin_path - if image != self._attr_image_url: - self._attr_image_last_updated = datetime.now() + """Return the image_url for this plant.""" + url = self.entity_description.url_fn(self.plant) - return image + if url != self._attr_image_url: + self._cached_image = None + self._attr_image_last_updated = datetime.now() + return url diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index a10fa5bfc47..67bb991a437 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -61,6 +61,14 @@ "name": "Sensor update available" } }, + "image": { + "plant_image": { + "name": "Plant image" + }, + "plant_image_user": { + "name": "User image" + } + }, "sensor": { "scientific_name": { "name": "Scientific name" diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py index a43741b9249..34cbbdbbb1c 100644 --- a/homeassistant/components/gc100/__init__.py +++ b/homeassistant/components/gc100/__init__.py @@ -1,5 +1,7 @@ """Support for controlling Global Cache gc100.""" +from __future__ import annotations + import gc100 import voluptuous as vol @@ -7,13 +9,14 @@ from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey CONF_PORTS = "ports" DEFAULT_PORT = 4998 DOMAIN = "gc100" -DATA_GC100 = "gc100" +DATA_GC100: HassKey[GC100Device] = HassKey("gc100") CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py index cef798935cb..3dcbb355d3a 100644 --- a/homeassistant/components/gc100/binary_sensor.py +++ b/homeassistant/components/gc100/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_PORTS, DATA_GC100 +from . import CONF_PORTS, DATA_GC100, GC100Device _SENSORS_SCHEMA = vol.Schema({cv.string: cv.string}) @@ -31,7 +31,7 @@ def setup_platform( ) -> None: """Set up the GC100 devices.""" binary_sensors = [] - ports = config[CONF_PORTS] + ports: list[dict[str, str]] = config[CONF_PORTS] for port in ports: for port_addr, port_name in port.items(): binary_sensors.append( @@ -43,23 +43,23 @@ def setup_platform( class GC100BinarySensor(BinarySensorEntity): """Representation of a binary sensor from GC100.""" - def __init__(self, name, port_addr, gc100): + def __init__(self, name: str, port_addr: str, gc100: GC100Device) -> None: """Initialize the GC100 binary sensor.""" self._name = name or DEVICE_DEFAULT_NAME self._port_addr = port_addr self._gc100 = gc100 - self._state = None + self._state: bool | None = None # Subscribe to be notified about state changes (PUSH) self._gc100.subscribe(self._port_addr, self.set_state) @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the entity.""" return self._state @@ -67,7 +67,7 @@ class GC100BinarySensor(BinarySensorEntity): """Update the sensor state.""" self._gc100.read_sensor(self._port_addr, self.set_state) - def set_state(self, state): + def set_state(self, state: int) -> None: """Set the current state.""" self._state = state == 1 self.schedule_update_ha_state() diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py index 23b178cc647..bb4742bafdf 100644 --- a/homeassistant/components/gc100/switch.py +++ b/homeassistant/components/gc100/switch.py @@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_PORTS, DATA_GC100 +from . import CONF_PORTS, DATA_GC100, GC100Device _SWITCH_SCHEMA = vol.Schema({cv.string: cv.string}) @@ -33,7 +33,7 @@ def setup_platform( ) -> None: """Set up the GC100 devices.""" switches = [] - ports = config[CONF_PORTS] + ports: list[dict[str, str]] = config[CONF_PORTS] for port in ports: for port_addr, port_name in port.items(): switches.append(GC100Switch(port_name, port_addr, hass.data[DATA_GC100])) @@ -43,20 +43,20 @@ def setup_platform( class GC100Switch(SwitchEntity): """Represent a switch/relay from GC100.""" - def __init__(self, name, port_addr, gc100): + def __init__(self, name: str, port_addr: str, gc100: GC100Device) -> None: """Initialize the GC100 switch.""" self._name = name or DEVICE_DEFAULT_NAME self._port_addr = port_addr self._gc100 = gc100 - self._state = None + self._state: bool | None = None @property - def name(self): + def name(self) -> str: """Return the name of the switch.""" return self._name @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the entity.""" return self._state @@ -72,7 +72,7 @@ class GC100Switch(SwitchEntity): """Update the sensor state.""" self._gc100.read_sensor(self._port_addr, self.set_state) - def set_state(self, state): + def set_state(self, state: int) -> None: """Set the current state.""" self._state = state == 1 self.schedule_update_ha_state() diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index e96246b70bf..1a8f2fce236 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -25,22 +25,17 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import ( # noqa: F401 - CONF_CATEGORIES, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - FEED, - PLATFORMS, -) +from .const import CONF_CATEGORIES, DEFAULT_SCAN_INTERVAL, PLATFORMS # noqa: F401 _LOGGER = logging.getLogger(__name__) +type GdacsConfigEntry = ConfigEntry[GdacsFeedEntityManager] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, config_entry: GdacsConfigEntry +) -> bool: """Set up the GDACS component as config entry.""" - hass.data.setdefault(DOMAIN, {}) - feeds = hass.data[DOMAIN].setdefault(FEED, {}) - radius = config_entry.data[CONF_RADIUS] if hass.config.units is US_CUSTOMARY_SYSTEM: radius = DistanceConverter.convert( @@ -48,16 +43,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) # Create feed entity manager for all platforms. manager = GdacsFeedEntityManager(hass, config_entry, radius) - feeds[config_entry.entry_id] = manager + config_entry.runtime_data = manager _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) await manager.async_init() return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GdacsConfigEntry) -> bool: """Unload an GDACS component config entry.""" - manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED].pop(entry.entry_id) - await manager.async_stop() + await entry.runtime_data.async_stop() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -65,7 +59,7 @@ class GdacsFeedEntityManager: """Feed Entity Manager for GDACS feed.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, radius_in_km: float + self, hass: HomeAssistant, config_entry: GdacsConfigEntry, radius_in_km: float ) -> None: """Initialize the Feed Entity Manager.""" self._hass = hass diff --git a/homeassistant/components/gdacs/const.py b/homeassistant/components/gdacs/const.py index d1028ed2d08..c040809a357 100644 --- a/homeassistant/components/gdacs/const.py +++ b/homeassistant/components/gdacs/const.py @@ -10,8 +10,6 @@ DOMAIN = "gdacs" PLATFORMS = [Platform.GEO_LOCATION, Platform.SENSOR] -FEED = "feed" - CONF_CATEGORIES = "categories" DEFAULT_ICON = "mdi:alert" diff --git a/homeassistant/components/gdacs/diagnostics.py b/homeassistant/components/gdacs/diagnostics.py index 435e28ca1ae..9501fb29dd2 100644 --- a/homeassistant/components/gdacs/diagnostics.py +++ b/homeassistant/components/gdacs/diagnostics.py @@ -7,26 +7,23 @@ from typing import Any from aio_georss_client.status_update import StatusUpdate from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from . import GdacsFeedEntityManager -from .const import DOMAIN, FEED +from . import GdacsConfigEntry TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: GdacsConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" data: dict[str, Any] = { "info": async_redact_data(config_entry.data, TO_REDACT), } - manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][config_entry.entry_id] - status_info: StatusUpdate = manager.status_info() + status_info: StatusUpdate = config_entry.runtime_data.status_info() if status_info: data["service"] = { "status": status_info.status, diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index d277ee54f6b..e4057633101 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -10,7 +10,6 @@ from typing import Any from aio_georss_gdacs.feed_entry import GdacsFeedEntry from homeassistant.components.geo_location import GeolocationEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -19,8 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import GdacsFeedEntityManager -from .const import DEFAULT_ICON, DOMAIN, FEED +from . import GdacsConfigEntry, GdacsFeedEntityManager +from .const import DEFAULT_ICON _LOGGER = logging.getLogger(__name__) @@ -53,11 +52,11 @@ SOURCE = "gdacs" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GdacsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GDACS Feed platform.""" - manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] + manager = entry.runtime_data @callback def async_add_geolocation( diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index a204addd414..f23a02d92b0 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -10,15 +10,14 @@ from typing import Any from aio_georss_client.status_update import StatusUpdate from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from . import GdacsFeedEntityManager -from .const import DOMAIN, FEED +from . import GdacsConfigEntry, GdacsFeedEntityManager +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -38,12 +37,11 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GdacsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GDACS Feed platform.""" - manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] - sensor = GdacsSensor(entry, manager) + sensor = GdacsSensor(entry, entry.runtime_data) async_add_entities([sensor]) @@ -57,7 +55,7 @@ class GdacsSensor(SensorEntity): _attr_translation_key = "alerts" def __init__( - self, config_entry: ConfigEntry, manager: GdacsFeedEntityManager + self, config_entry: GdacsConfigEntry, manager: GdacsFeedEntityManager ) -> None: """Initialize entity.""" assert config_entry.unique_id diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index b4a6014c5a4..a12994c1a75 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -5,11 +5,18 @@ import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from homeassistant.helpers.typing import ConfigType DOMAIN = "generic_hygrostat" @@ -88,6 +95,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_HUMIDIFIER], ) + def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_HUMIDIFIER: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + # We use async_handle_source_entity_changes to track changes to the humidifer, + # but not the humidity sensor because the generic_hygrostat adds itself to the + # humidifier's device. + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_HUMIDIFIER] + ), + source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER], + source_entity_removed=source_entity_removed, + ) + ) + + async def async_sensor_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + data = event.data + if data["action"] != "update": + return + if "entity_id" not in data["changes"]: + return + + # Entity_id changed, update the config entry + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SENSOR: data["entity_id"]}, + ) + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, entry.options[CONF_SENSOR], async_sensor_updated + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, (Platform.HUMIDIFIER,)) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index dc43049a262..3e2af8598de 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -1,12 +1,16 @@ """The generic_thermostat component.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes -from .const import CONF_HEATER, PLATFORMS +from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -17,6 +21,55 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.entry_id, entry.options[CONF_HEATER], ) + + def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_HEATER: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + # We use async_handle_source_entity_changes to track changes to the heater, but + # not the temperature sensor because the generic_hygrostat adds itself to the + # heater's device. + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_HEATER] + ), + source_entity_id_or_uuid=entry.options[CONF_HEATER], + source_entity_removed=source_entity_removed, + ) + ) + + async def async_sensor_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + data = event.data + if data["action"] != "update": + return + if "entity_id" not in data["changes"]: + return + + # Entity_id changed, update the config entry + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SENSOR: data["entity_id"]}, + ) + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, entry.options[CONF_SENSOR], async_sensor_updated + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True diff --git a/homeassistant/components/geocaching/__init__.py b/homeassistant/components/geocaching/__init__.py index aa2926df949..144249ac42f 100644 --- a/homeassistant/components/geocaching/__init__.py +++ b/homeassistant/components/geocaching/__init__.py @@ -1,6 +1,5 @@ """The Geocaching integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -8,13 +7,12 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( async_get_config_entry_implementation, ) -from .const import DOMAIN -from .coordinator import GeocachingDataUpdateCoordinator +from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GeocachingConfigEntry) -> bool: """Set up Geocaching from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) @@ -25,15 +23,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GeocachingConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/geocaching/coordinator.py b/homeassistant/components/geocaching/coordinator.py index 41b59d049af..bfe82069650 100644 --- a/homeassistant/components/geocaching/coordinator.py +++ b/homeassistant/components/geocaching/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from geocachingapi.exceptions import GeocachingApiError +from geocachingapi.exceptions import GeocachingApiError, GeocachingInvalidSettingsError from geocachingapi.geocachingapi import GeocachingApi from geocachingapi.models import GeocachingStatus @@ -14,14 +14,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, ENVIRONMENT, LOGGER, UPDATE_INTERVAL +type GeocachingConfigEntry = ConfigEntry[GeocachingDataUpdateCoordinator] + class GeocachingDataUpdateCoordinator(DataUpdateCoordinator[GeocachingStatus]): """Class to manage fetching Geocaching data from single endpoint.""" - config_entry: ConfigEntry + config_entry: GeocachingConfigEntry def __init__( - self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session + self, + hass: HomeAssistant, + *, + entry: GeocachingConfigEntry, + session: OAuth2Session, ) -> None: """Initialize global Geocaching data updater.""" self.session = session @@ -33,6 +39,7 @@ class GeocachingDataUpdateCoordinator(DataUpdateCoordinator[GeocachingStatus]): return str(token) client_session = async_get_clientsession(hass) + self.geocaching = GeocachingApi( environment=ENVIRONMENT, token=session.token["access_token"], @@ -49,7 +56,10 @@ class GeocachingDataUpdateCoordinator(DataUpdateCoordinator[GeocachingStatus]): ) async def _async_update_data(self) -> GeocachingStatus: + """Fetch the latest Geocaching status.""" try: return await self.geocaching.update() + except GeocachingInvalidSettingsError as error: + raise UpdateFailed(f"Invalid integration configuration: {error}") from error except GeocachingApiError as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index c7894afc5ac..5ceef21dfbf 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -9,14 +9,13 @@ from typing import cast from geocachingapi.models import GeocachingStatus from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import GeocachingDataUpdateCoordinator +from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -65,11 +64,11 @@ SENSORS: tuple[GeocachingSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeocachingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Geocaching sensor entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( GeocachingSensor(coordinator, description) for description in SENSORS ) @@ -94,6 +93,7 @@ class GeocachingSensor( self._attr_unique_id = ( f"{coordinator.data.user.reference_code}_{description.key}" ) + self._attr_device_info = DeviceInfo( name=f"Geocaching {coordinator.data.user.username}", identifiers={(DOMAIN, cast(str, coordinator.data.user.reference_code))}, diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 46a3482ce1e..6ced8af8bc6 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -20,9 +20,12 @@ from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN +type GeofencyConfigEntry = ConfigEntry[set[str]] + PLATFORMS = [Platform.DEVICE_TRACKER] CONF_MOBILE_BEACONS = "mobile_beacons" @@ -75,16 +78,13 @@ WEBHOOK_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_DATA_GEOFENCY: HassKey[list[str]] = HassKey(DOMAIN) + async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: """Set up the Geofency component.""" - config = hass_config.get(DOMAIN, {}) - mobile_beacons = config.get(CONF_MOBILE_BEACONS, []) - hass.data[DOMAIN] = { - "beacons": [slugify(beacon) for beacon in mobile_beacons], - "devices": set(), - "unsub_device_tracker": {}, - } + mobile_beacons = hass_config.get(DOMAIN, {}).get(CONF_MOBILE_BEACONS, []) + hass.data[_DATA_GEOFENCY] = [slugify(beacon) for beacon in mobile_beacons] return True @@ -99,7 +99,7 @@ async def handle_webhook( text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY ) - if _is_mobile_beacon(data, hass.data[DOMAIN]["beacons"]): + if _is_mobile_beacon(data, hass.data[_DATA_GEOFENCY]): return _set_location(hass, data, None) if data["entry"] == LOCATION_ENTRY: location_name = data["name"] @@ -140,8 +140,9 @@ def _set_location(hass, data, location_name): return web.Response(text=f"Setting location for {device}") -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GeofencyConfigEntry) -> bool: """Configure based on config entry.""" + entry.runtime_data = set() webhook.async_register( hass, DOMAIN, "Geofency", entry.data[CONF_WEBHOOK_ID], handle_webhook ) @@ -150,10 +151,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GeofencyConfigEntry) -> bool: """Unload a config entry.""" webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - hass.data[DOMAIN]["unsub_device_tracker"].pop(entry.entry_id)() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index c74dad1cebb..4a57eaab2f5 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -1,7 +1,6 @@ """Support for the Geofency device tracker platform.""" from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -10,12 +9,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE +from . import TRACKER_UPDATE, GeofencyConfigEntry +from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GeofencyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Geofency config entry.""" @@ -23,14 +23,16 @@ async def async_setup_entry( @callback def _receive_data(device, gps, location_name, attributes): """Fire HA event to set location.""" - if device in hass.data[GF_DOMAIN]["devices"]: + if device in config_entry.runtime_data: return - hass.data[GF_DOMAIN]["devices"].add(device) + config_entry.runtime_data.add(device) - async_add_entities([GeofencyEntity(device, gps, location_name, attributes)]) + async_add_entities( + [GeofencyEntity(config_entry, device, gps, location_name, attributes)] + ) - hass.data[GF_DOMAIN]["unsub_device_tracker"][config_entry.entry_id] = ( + config_entry.async_on_unload( async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) ) @@ -45,8 +47,8 @@ async def async_setup_entry( } if dev_ids: - hass.data[GF_DOMAIN]["devices"].update(dev_ids) - async_add_entities(GeofencyEntity(dev_id) for dev_id in dev_ids) + config_entry.runtime_data.update(dev_ids) + async_add_entities(GeofencyEntity(config_entry, dev_id) for dev_id in dev_ids) class GeofencyEntity(TrackerEntity, RestoreEntity): @@ -55,8 +57,9 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, device, gps=None, location_name=None, attributes=None): + def __init__(self, entry, device, gps=None, location_name=None, attributes=None): """Set up Geofency entity.""" + self._entry = entry self._attr_extra_state_attributes = attributes or {} self._name = device self._attr_location_name = location_name @@ -66,7 +69,7 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): self._unsub_dispatcher = None self._attr_unique_id = device self._attr_device_info = DeviceInfo( - identifiers={(GF_DOMAIN, device)}, + identifiers={(DOMAIN, device)}, name=device, ) @@ -93,7 +96,7 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): """Clean up after entity before removal.""" await super().async_will_remove_from_hass() self._unsub_dispatcher() - self.hass.data[GF_DOMAIN]["devices"].remove(self.unique_id) + self._entry.runtime_data.remove(self.unique_id) @callback def _async_receive_data(self, device, gps, location_name, attributes): diff --git a/homeassistant/components/geonetnz_quakes/__init__.py b/homeassistant/components/geonetnz_quakes/__init__.py index b9443d4aed8..a1522862dca 100644 --- a/homeassistant/components/geonetnz_quakes/__init__.py +++ b/homeassistant/components/geonetnz_quakes/__init__.py @@ -31,7 +31,6 @@ from .const import ( DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, - FEED, PLATFORMS, ) @@ -59,6 +58,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type GeonetnzQuakesConfigEntry = ConfigEntry[GeonetnzQuakesFeedEntityManager] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the GeoNet NZ Quakes component.""" @@ -89,11 +90,10 @@ 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, config_entry: GeonetnzQuakesConfigEntry +) -> bool: """Set up the GeoNet NZ Quakes component as config entry.""" - hass.data.setdefault(DOMAIN, {}) - feeds = hass.data[DOMAIN].setdefault(FEED, {}) - radius = config_entry.data[CONF_RADIUS] if hass.config.units is US_CUSTOMARY_SYSTEM: radius = DistanceConverter.convert( @@ -101,16 +101,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) # Create feed entity manager for all platforms. manager = GeonetnzQuakesFeedEntityManager(hass, config_entry, radius) - feeds[config_entry.entry_id] = manager + config_entry.runtime_data = manager _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) await manager.async_init() return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GeonetnzQuakesConfigEntry +) -> bool: """Unload an GeoNet NZ Quakes component config entry.""" - manager = hass.data[DOMAIN][FEED].pop(entry.entry_id) - await manager.async_stop() + await entry.runtime_data.async_stop() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py index db529a17fbe..9c0f1a08c6f 100644 --- a/homeassistant/components/geonetnz_quakes/const.py +++ b/homeassistant/components/geonetnz_quakes/const.py @@ -11,8 +11,6 @@ PLATFORMS = [Platform.GEO_LOCATION, Platform.SENSOR] CONF_MINIMUM_MAGNITUDE = "minimum_magnitude" CONF_MMI = "mmi" -FEED = "feed" - DEFAULT_FILTER_TIME_INTERVAL = timedelta(days=7) DEFAULT_MINIMUM_MAGNITUDE = 0.0 DEFAULT_MMI = 3 diff --git a/homeassistant/components/geonetnz_quakes/diagnostics.py b/homeassistant/components/geonetnz_quakes/diagnostics.py index fbe9bf511aa..ebb6a2e9046 100644 --- a/homeassistant/components/geonetnz_quakes/diagnostics.py +++ b/homeassistant/components/geonetnz_quakes/diagnostics.py @@ -5,28 +5,23 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from . import GeonetnzQuakesFeedEntityManager -from .const import DOMAIN, FEED +from . import GeonetnzQuakesConfigEntry TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: GeonetnzQuakesConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" data: dict[str, Any] = { "info": async_redact_data(config_entry.data, TO_REDACT), } - manager: GeonetnzQuakesFeedEntityManager = hass.data[DOMAIN][FEED][ - config_entry.entry_id - ] - status_info = manager.status_info() + status_info = config_entry.runtime_data.status_info() if status_info: data["service"] = { "status": status_info.status, diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index 96a1c3c09b2..e67d22c850f 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -9,7 +9,6 @@ from typing import Any from aio_geojson_geonetnz_quakes.feed_entry import GeonetnzQuakesFeedEntry from homeassistant.components.geo_location import GeolocationEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -18,8 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import GeonetnzQuakesFeedEntityManager -from .const import DOMAIN, FEED +from . import GeonetnzQuakesConfigEntry, GeonetnzQuakesFeedEntityManager _LOGGER = logging.getLogger(__name__) @@ -39,11 +37,11 @@ SOURCE = "geonetnz_quakes" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeonetnzQuakesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoNet NZ Quakes Feed platform.""" - manager: GeonetnzQuakesFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] + manager = entry.runtime_data @callback def async_add_geolocation( diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index b8a1e2dd4db..cc4b4e16282 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -5,13 +5,12 @@ from __future__ import annotations import logging from homeassistant.components.sensor import SensorEntity -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 homeassistant.util import dt as dt_util -from .const import DOMAIN, FEED +from . import GeonetnzQuakesConfigEntry _LOGGER = logging.getLogger(__name__) @@ -32,11 +31,11 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeonetnzQuakesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoNet NZ Quakes Feed platform.""" - manager = hass.data[DOMAIN][FEED][entry.entry_id] + manager = entry.runtime_data sensor = GeonetnzQuakesSensor(entry.entry_id, entry.unique_id, entry.title, manager) async_add_entities([sensor]) _LOGGER.debug("Sensor setup done") diff --git a/homeassistant/components/geonetnz_volcano/__init__.py b/homeassistant/components/geonetnz_volcano/__init__.py index b08d6d62c55..c3ceeab33f8 100644 --- a/homeassistant/components/geonetnz_volcano/__init__.py +++ b/homeassistant/components/geonetnz_volcano/__init__.py @@ -29,7 +29,6 @@ from .const import ( DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, - FEED, IMPERIAL_UNITS, PLATFORMS, ) @@ -52,6 +51,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type GeonetnzVolcanoConfigEntry = ConfigEntry[GeonetnzVolcanoFeedEntityManager] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the GeoNet NZ Volcano component.""" @@ -84,11 +85,10 @@ 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, config_entry: GeonetnzVolcanoConfigEntry +) -> bool: """Set up the GeoNet NZ Volcano component as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(FEED, {}) - radius = config_entry.data[CONF_RADIUS] unit_system = config_entry.data[CONF_UNIT_SYSTEM] if unit_system == IMPERIAL_UNITS: @@ -97,16 +97,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) # Create feed entity manager for all platforms. manager = GeonetnzVolcanoFeedEntityManager(hass, config_entry, radius, unit_system) - hass.data[DOMAIN][FEED][config_entry.entry_id] = manager + config_entry.runtime_data = manager _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) await manager.async_init() return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GeonetnzVolcanoConfigEntry +) -> bool: """Unload an GeoNet NZ Volcano component config entry.""" - manager = hass.data[DOMAIN][FEED].pop(entry.entry_id) - await manager.async_stop() + await entry.runtime_data.async_stop() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/geonetnz_volcano/const.py b/homeassistant/components/geonetnz_volcano/const.py index be04a25d27a..98ac69fec19 100644 --- a/homeassistant/components/geonetnz_volcano/const.py +++ b/homeassistant/components/geonetnz_volcano/const.py @@ -6,8 +6,6 @@ from homeassistant.const import Platform DOMAIN = "geonetnz_volcano" -FEED = "feed" - ATTR_ACTIVITY = "activity" ATTR_DISTANCE = "distance" ATTR_EXTERNAL_ID = "external_id" diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index bde04acb895..159806778ce 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -13,14 +12,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter +from . import GeonetnzVolcanoConfigEntry from .const import ( ATTR_ACTIVITY, ATTR_DISTANCE, ATTR_EXTERNAL_ID, ATTR_HAZARDS, DEFAULT_ICON, - DOMAIN, - FEED, IMPERIAL_UNITS, ) @@ -32,11 +30,11 @@ ATTR_LAST_UPDATE_SUCCESSFUL = "feed_last_update_successful" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeonetnzVolcanoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoNet NZ Volcano Feed platform.""" - manager = hass.data[DOMAIN][FEED][entry.entry_id] + manager = entry.runtime_data @callback def async_add_sensor(feed_manager, external_id, unit_system): diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index 07dbd3bd29b..dd50b4ba076 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -8,6 +8,6 @@ "integration_type": "system", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["go2rtc-client==0.1.2"], + "requirements": ["go2rtc-client==0.2.1"], "single_config_entry": true } diff --git a/homeassistant/components/gogogate2/__init__.py b/homeassistant/components/gogogate2/__init__.py index ceb07c99849..1afb77a4f70 100644 --- a/homeassistant/components/gogogate2/__init__.py +++ b/homeassistant/components/gogogate2/__init__.py @@ -1,16 +1,16 @@ """The gogogate2 component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant -from .common import get_data_update_coordinator +from .common import create_data_update_coordinator from .const import DEVICE_TYPE_GOGOGATE2 +from .coordinator import GogoGateConfigEntry PLATFORMS = [Platform.COVER, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GogoGateConfigEntry) -> bool: """Do setup of Gogogate2.""" # Update the config entry. @@ -24,14 +24,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if config_updates: hass.config_entries.async_update_entry(entry, data=config_updates) - data_update_coordinator = get_data_update_coordinator(hass, entry) + data_update_coordinator = create_data_update_coordinator(hass, entry) await data_update_coordinator.async_config_entry_first_refresh() + entry.runtime_data = data_update_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: GogoGateConfigEntry) -> bool: """Unload Gogogate2 config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 8506414ca33..a98e1194e5b 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -16,7 +16,6 @@ from ismartgate import ( ) from ismartgate.common import AbstractDoor -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE, CONF_IP_ADDRESS, @@ -27,8 +26,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import UpdateFailed -from .const import DATA_UPDATE_COORDINATOR, DEVICE_TYPE_ISMARTGATE, DOMAIN -from .coordinator import DeviceDataUpdateCoordinator +from .const import DEVICE_TYPE_ISMARTGATE +from .coordinator import DeviceDataUpdateCoordinator, GogoGateConfigEntry _LOGGER = logging.getLogger(__name__) @@ -41,47 +40,40 @@ class StateData(NamedTuple): door: AbstractDoor | None -def get_data_update_coordinator( - hass: HomeAssistant, config_entry: ConfigEntry +def create_data_update_coordinator( + hass: HomeAssistant, config_entry: GogoGateConfigEntry ) -> DeviceDataUpdateCoordinator: """Get an update coordinator.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(config_entry.entry_id, {}) - config_entry_data = hass.data[DOMAIN][config_entry.entry_id] + api = get_api(hass, config_entry.data) - if DATA_UPDATE_COORDINATOR not in config_entry_data: - api = get_api(hass, config_entry.data) + async def async_update_data() -> GogoGate2InfoResponse | ISmartGateInfoResponse: + try: + return await api.async_info() + except Exception as exception: + raise UpdateFailed( + f"Error communicating with API: {exception}" + ) from exception - async def async_update_data() -> GogoGate2InfoResponse | ISmartGateInfoResponse: - try: - return await api.async_info() - except Exception as exception: - raise UpdateFailed( - f"Error communicating with API: {exception}" - ) from exception - - config_entry_data[DATA_UPDATE_COORDINATOR] = DeviceDataUpdateCoordinator( - hass, - config_entry, - _LOGGER, - api, - # Name of the data. For logging purposes. - name="gogogate2", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=5), - ) - - return config_entry_data[DATA_UPDATE_COORDINATOR] + return DeviceDataUpdateCoordinator( + hass, + config_entry, + _LOGGER, + api, + # Name of the data. For logging purposes. + name="gogogate2", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=5), + ) -def cover_unique_id(config_entry: ConfigEntry, door: AbstractDoor) -> str: +def cover_unique_id(config_entry: GogoGateConfigEntry, door: AbstractDoor) -> str: """Generate a cover entity unique id.""" return f"{config_entry.unique_id}_{door.door_id}" def sensor_unique_id( - config_entry: ConfigEntry, door: AbstractDoor, sensor_type: str + config_entry: GogoGateConfigEntry, door: AbstractDoor, sensor_type: str ) -> str: """Generate a cover entity unique id.""" return f"{config_entry.unique_id}_{door.door_id}_{sensor_type}" diff --git a/homeassistant/components/gogogate2/const.py b/homeassistant/components/gogogate2/const.py index 2f6ac76122f..a5122b7e215 100644 --- a/homeassistant/components/gogogate2/const.py +++ b/homeassistant/components/gogogate2/const.py @@ -1,7 +1,7 @@ """Constants for integration.""" DOMAIN = "gogogate2" -DATA_UPDATE_COORDINATOR = "data_update_coordinator" + DEVICE_TYPE_GOGOGATE2 = "gogogate2" DEVICE_TYPE_ISMARTGATE = "ismartgate" MANUFACTURER = "Remsol" diff --git a/homeassistant/components/gogogate2/coordinator.py b/homeassistant/components/gogogate2/coordinator.py index c2e7cc47b46..5f5a082084c 100644 --- a/homeassistant/components/gogogate2/coordinator.py +++ b/homeassistant/components/gogogate2/coordinator.py @@ -13,18 +13,20 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +type GogoGateConfigEntry = ConfigEntry[DeviceDataUpdateCoordinator] + class DeviceDataUpdateCoordinator( DataUpdateCoordinator[GogoGate2InfoResponse | ISmartGateInfoResponse] ): """Manages polling for state changes from the device.""" - config_entry: ConfigEntry + config_entry: GogoGateConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, logger: logging.Logger, api: AbstractGateApi, *, diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 9492108d4b2..539e53598fb 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -16,22 +16,21 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import cover_unique_id, get_data_update_coordinator -from .coordinator import DeviceDataUpdateCoordinator +from .common import cover_unique_id +from .coordinator import DeviceDataUpdateCoordinator, GogoGateConfigEntry from .entity import GoGoGate2Entity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the config entry.""" - data_update_coordinator = get_data_update_coordinator(hass, config_entry) + data_update_coordinator = config_entry.runtime_data async_add_entities( [ @@ -48,7 +47,7 @@ class DeviceCover(GoGoGate2Entity, CoverEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, data_update_coordinator: DeviceDataUpdateCoordinator, door: AbstractDoor, ) -> None: diff --git a/homeassistant/components/gogogate2/entity.py b/homeassistant/components/gogogate2/entity.py index 8a699f6101b..a6879f038bc 100644 --- a/homeassistant/components/gogogate2/entity.py +++ b/homeassistant/components/gogogate2/entity.py @@ -4,13 +4,12 @@ from __future__ import annotations from ismartgate.common import AbstractDoor, get_door_by_id -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER -from .coordinator import DeviceDataUpdateCoordinator +from .coordinator import DeviceDataUpdateCoordinator, GogoGateConfigEntry class GoGoGate2Entity(CoordinatorEntity[DeviceDataUpdateCoordinator]): @@ -18,7 +17,7 @@ class GoGoGate2Entity(CoordinatorEntity[DeviceDataUpdateCoordinator]): def __init__( self, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, data_update_coordinator: DeviceDataUpdateCoordinator, door: AbstractDoor, unique_id: str, diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index ce86ca9ac43..c594671b34f 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -11,13 +11,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import get_data_update_coordinator, sensor_unique_id -from .coordinator import DeviceDataUpdateCoordinator +from .common import sensor_unique_id +from .coordinator import DeviceDataUpdateCoordinator, GogoGateConfigEntry from .entity import GoGoGate2Entity SENSOR_ID_WIRED = "WIRE" @@ -25,11 +24,11 @@ SENSOR_ID_WIRED = "WIRE" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the config entry.""" - data_update_coordinator = get_data_update_coordinator(hass, config_entry) + data_update_coordinator = config_entry.runtime_data sensors = chain( [ @@ -69,7 +68,7 @@ class DoorSensorBattery(DoorSensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, data_update_coordinator: DeviceDataUpdateCoordinator, door: AbstractDoor, ) -> None: @@ -97,7 +96,7 @@ class DoorSensorTemperature(DoorSensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, data_update_coordinator: DeviceDataUpdateCoordinator, door: AbstractDoor, ) -> None: diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index 02c1d5beac7..b6637bc8b50 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -2,26 +2,17 @@ from goodwe import InverterError, connect -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo -from .const import ( - CONF_MODEL_FAMILY, - DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE_INFO, - KEY_INVERTER, - PLATFORMS, -) -from .coordinator import GoodweUpdateCoordinator +from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS +from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool: """Set up the Goodwe components from a config entry.""" - hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] model_family = entry.data[CONF_MODEL_FAMILY] @@ -50,11 +41,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - KEY_INVERTER: inverter, - KEY_COORDINATOR: coordinator, - KEY_DEVICE_INFO: device_info, - } + entry.runtime_data = GoodweRuntimeData( + inverter=inverter, + coordinator=coordinator, + device_info=device_info, + ) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -63,18 +54,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: GoodweConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/goodwe/button.py b/homeassistant/components/goodwe/button.py index e93b23570db..64d1e08276d 100644 --- a/homeassistant/components/goodwe/button.py +++ b/homeassistant/components/goodwe/button.py @@ -8,13 +8,12 @@ import logging from goodwe import Inverter, InverterError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER +from .coordinator import GoodweConfigEntry _LOGGER = logging.getLogger(__name__) @@ -36,12 +35,12 @@ SYNCHRONIZE_CLOCK = GoodweButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter button entities from a config entry.""" - inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] - device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO] + inverter = config_entry.runtime_data.inverter + device_info = config_entry.runtime_data.device_info # read current time from the inverter try: diff --git a/homeassistant/components/goodwe/const.py b/homeassistant/components/goodwe/const.py index 730433c4a66..432d18e5867 100644 --- a/homeassistant/components/goodwe/const.py +++ b/homeassistant/components/goodwe/const.py @@ -12,7 +12,3 @@ DEFAULT_NAME = "GoodWe" SCAN_INTERVAL = timedelta(seconds=10) CONF_MODEL_FAMILY = "model_family" - -KEY_INVERTER = "inverter" -KEY_COORDINATOR = "coordinator" -KEY_DEVICE_INFO = "device_info" diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py index 914ba3155b4..3236b95d9e0 100644 --- a/homeassistant/components/goodwe/coordinator.py +++ b/homeassistant/components/goodwe/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any @@ -9,22 +10,34 @@ from goodwe import Inverter, InverterError, RequestFailedException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type GoodweConfigEntry = ConfigEntry[GoodweRuntimeData] + + +@dataclass +class GoodweRuntimeData: + """Data class for runtime data.""" + + inverter: Inverter + coordinator: GoodweUpdateCoordinator + device_info: DeviceInfo + class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Gather data for the energy device.""" - config_entry: ConfigEntry + config_entry: GoodweConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: GoodweConfigEntry, inverter: Inverter, ) -> None: """Initialize update coordinator.""" diff --git a/homeassistant/components/goodwe/diagnostics.py b/homeassistant/components/goodwe/diagnostics.py index 66806d31589..ece5f3b6507 100644 --- a/homeassistant/components/goodwe/diagnostics.py +++ b/homeassistant/components/goodwe/diagnostics.py @@ -4,19 +4,16 @@ from __future__ import annotations from typing import Any -from goodwe import Inverter - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, KEY_INVERTER +from .coordinator import GoodweConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: GoodweConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - inverter: Inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] + inverter = config_entry.runtime_data.inverter return { "config_entry": config_entry.as_dict(), diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 0a61ac19d64..0d200c2725c 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -13,13 +13,13 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER +from .const import DOMAIN +from .coordinator import GoodweConfigEntry _LOGGER = logging.getLogger(__name__) @@ -86,12 +86,12 @@ NUMBERS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter select entities from a config entry.""" - inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] - device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO] + inverter = config_entry.runtime_data.inverter + device_info = config_entry.runtime_data.device_info entities = [] diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index 340e10bfa0f..c26e8135b3f 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -5,13 +5,13 @@ import logging from goodwe import Inverter, InverterError, OperationMode from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER +from .const import DOMAIN +from .coordinator import GoodweConfigEntry _LOGGER = logging.getLogger(__name__) @@ -39,12 +39,12 @@ OPERATION_MODE = SelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter select entities from a config entry.""" - inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] - device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO] + inverter = config_entry.runtime_data.inverter + device_info = config_entry.runtime_data.device_info supported_modes = await inverter.get_operation_modes(False) # read current operating mode from the inverter diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index d2dce2770e4..c51827712d4 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -17,7 +17,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -39,8 +38,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER -from .coordinator import GoodweUpdateCoordinator +from .const import DOMAIN +from .coordinator import GoodweConfigEntry, GoodweUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -165,14 +164,14 @@ TEXT_SENSOR = GoodweSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GoodWe inverter from a config entry.""" entities: list[InverterSensor] = [] - inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO] + inverter = config_entry.runtime_data.inverter + coordinator = config_entry.runtime_data.coordinator + device_info = config_entry.runtime_data.device_info # Individual inverter sensors entities entities.extend( diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 2b7aeadc0ba..3c3d6577e6c 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -14,7 +14,6 @@ from gcal_sync.model import DateOrDatetime, Event import voluptuous as vol import yaml -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_ENTITIES, @@ -34,8 +33,6 @@ from homeassistant.helpers.entity import generate_entity_id from .api import ApiAuthImpl, get_feature_access from .const import ( - DATA_SERVICE, - DATA_STORE, DOMAIN, EVENT_DESCRIPTION, EVENT_END_DATE, @@ -50,7 +47,7 @@ from .const import ( EVENT_TYPES_CONF, FeatureAccess, ) -from .store import LocalCalendarStore +from .store import GoogleConfigEntry, GoogleRuntimeData, LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -139,11 +136,8 @@ ADD_EVENT_SERVICE_SCHEMA = vol.All( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bool: """Set up Google from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - # Validate google_calendars.yaml (if present) as soon as possible to return # helpful error messages. try: @@ -181,9 +175,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: calendar_service = GoogleCalendarService( ApiAuthImpl(async_get_clientsession(hass), session) ) - hass.data[DOMAIN][entry.entry_id][DATA_SERVICE] = calendar_service - hass.data[DOMAIN][entry.entry_id][DATA_STORE] = LocalCalendarStore( - hass, entry.entry_id + entry.runtime_data = GoogleRuntimeData( + service=calendar_service, + store=LocalCalendarStore(hass, entry.entry_id), ) if entry.unique_id is None: @@ -207,27 +201,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def async_entry_has_scopes(entry: ConfigEntry) -> bool: +def async_entry_has_scopes(entry: GoogleConfigEntry) -> bool: """Verify that the config entry desired scope is present in the oauth token.""" access = get_feature_access(entry) token_scopes = entry.data.get("token", {}).get("scope", []) return access.scope in token_scopes -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> 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) -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> None: """Reload config entry if the access options change.""" if not async_entry_has_scopes(entry): await hass.config_entries.async_reload(entry.entry_id) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> None: """Handle removal of a local storage.""" store = LocalCalendarStore(hass, entry.entry_id) await store.async_remove() diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 194c2a0b4a5..efbbec73017 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -17,7 +17,6 @@ from oauth2client.client import ( ) from homeassistant.components.application_credentials import AuthImplementation -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.event import ( @@ -27,6 +26,7 @@ from homeassistant.helpers.event import ( from homeassistant.util import dt as dt_util from .const import CONF_CALENDAR_ACCESS, DEFAULT_FEATURE_ACCESS, FeatureAccess +from .store import GoogleConfigEntry _LOGGER = logging.getLogger(__name__) @@ -155,7 +155,7 @@ class DeviceFlow: self._listener() -def get_feature_access(config_entry: ConfigEntry) -> FeatureAccess: +def get_feature_access(config_entry: GoogleConfigEntry) -> FeatureAccess: """Return the desired calendar feature access.""" if config_entry.options and CONF_CALENDAR_ACCESS in config_entry.options: return FeatureAccess[config_entry.options[CONF_CALENDAR_ACCESS]] diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index a62d2bf1d6b..6fef46395e8 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -37,7 +37,6 @@ from homeassistant.components.calendar import ( extract_offset, is_offset_reached, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, PlatformNotReady @@ -52,7 +51,6 @@ from . import ( CONF_SEARCH, CONF_TRACK, DEFAULT_CONF_OFFSET, - DOMAIN, YAML_DEVICES, get_calendar_info, load_config, @@ -60,8 +58,6 @@ from . import ( ) from .api import get_feature_access from .const import ( - DATA_SERVICE, - DATA_STORE, EVENT_END_DATE, EVENT_END_DATETIME, EVENT_IN, @@ -72,6 +68,7 @@ from .const import ( FeatureAccess, ) from .coordinator import CalendarQueryUpdateCoordinator, CalendarSyncUpdateCoordinator +from .store import GoogleConfigEntry _LOGGER = logging.getLogger(__name__) @@ -109,7 +106,7 @@ class GoogleCalendarEntityDescription(CalendarEntityDescription): def _get_entity_descriptions( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, calendar_item: Calendar, calendar_info: Mapping[str, Any], ) -> list[GoogleCalendarEntityDescription]: @@ -202,12 +199,12 @@ def _get_entity_descriptions( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the google calendar platform.""" - calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE] - store = hass.data[DOMAIN][config_entry.entry_id][DATA_STORE] + calendar_service = config_entry.runtime_data.service + store = config_entry.runtime_data.store try: result = await calendar_service.async_list_calendars() except ApiException as err: diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index add75f5e95b..15b9ed1c0d8 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -11,12 +11,7 @@ from gcal_sync.api import GoogleCalendarService from gcal_sync.exceptions import ApiException, ApiForbiddenException import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -38,6 +33,7 @@ from .const import ( CredentialType, FeatureAccess, ) +from .store import GoogleConfigEntry _LOGGER = logging.getLogger(__name__) @@ -240,7 +236,7 @@ class OAuth2FlowHandler( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, ) -> OptionsFlow: """Create an options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index 1e0b2fc910b..6613668cf91 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -9,9 +9,7 @@ DOMAIN = "google" CONF_CALENDAR_ACCESS = "calendar_access" CONF_CREDENTIAL_TYPE = "credential_type" DATA_CALENDARS = "calendars" -DATA_SERVICE = "service" DATA_CONFIG = "config" -DATA_STORE = "store" class FeatureAccess(Enum): diff --git a/homeassistant/components/google/coordinator.py b/homeassistant/components/google/coordinator.py index 4a8a3d9f167..9f51c60b069 100644 --- a/homeassistant/components/google/coordinator.py +++ b/homeassistant/components/google/coordinator.py @@ -14,12 +14,13 @@ from gcal_sync.sync import CalendarEventSyncManager from gcal_sync.timeline import Timeline from ical.iter import SortableItemValue -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +from .store import GoogleConfigEntry + _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -47,12 +48,12 @@ def _truncate_timeline(timeline: Timeline, max_events: int) -> Timeline: class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): """Coordinator for calendar RPC calls that use an efficient sync.""" - config_entry: ConfigEntry + config_entry: GoogleConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, sync: CalendarEventSyncManager, name: str, ) -> None: @@ -108,12 +109,12 @@ class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): for limitations in the calendar API for supporting search. """ - config_entry: ConfigEntry + config_entry: GoogleConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, calendar_service: GoogleCalendarService, name: str, calendar_id: str, diff --git a/homeassistant/components/google/diagnostics.py b/homeassistant/components/google/diagnostics.py index 1a6f498b4cd..6dc6e321a23 100644 --- a/homeassistant/components/google/diagnostics.py +++ b/homeassistant/components/google/diagnostics.py @@ -4,11 +4,10 @@ import datetime from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .const import DATA_STORE, DOMAIN +from .store import GoogleConfigEntry TO_REDACT = { "id", @@ -40,7 +39,7 @@ def redact_store(data: dict[str, Any]) -> dict[str, Any]: async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: GoogleConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" payload: dict[str, Any] = { @@ -49,7 +48,7 @@ async def async_get_config_entry_diagnostics( "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), } - store = hass.data[DOMAIN][config_entry.entry_id][DATA_STORE] - data = await store.async_load() - payload["store"] = redact_store(data) + store = config_entry.runtime_data.store + if data := await store.async_load(): + payload["store"] = redact_store(data) return payload diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index d6f2ee76615..fecd245869a 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.1", "oauth2client==4.1.3", "ical==9.2.4"] + "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.0"] } diff --git a/homeassistant/components/google/quality_scale.yaml b/homeassistant/components/google/quality_scale.yaml index 9ef6abdba90..43c86c54e28 100644 --- a/homeassistant/components/google/quality_scale.yaml +++ b/homeassistant/components/google/quality_scale.yaml @@ -40,11 +40,7 @@ rules: 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`. + runtime-data: done # Silver log-when-unavailable: done diff --git a/homeassistant/components/google/store.py b/homeassistant/components/google/store.py index c4d9e4c3e9c..4936a86f384 100644 --- a/homeassistant/components/google/store.py +++ b/homeassistant/components/google/store.py @@ -2,11 +2,14 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any +from gcal_sync.api import GoogleCalendarService from gcal_sync.store import CalendarStore +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store @@ -19,6 +22,16 @@ STORAGE_VERSION = 1 # Buffer writes every few minutes (plus guaranteed to be written at shutdown) STORAGE_SAVE_DELAY_SECONDS = 120 +type GoogleConfigEntry = ConfigEntry[GoogleRuntimeData] + + +@dataclass +class GoogleRuntimeData: + """Google runtime data.""" + + service: GoogleCalendarService + store: LocalCalendarStore + class LocalCalendarStore(CalendarStore): """Storage for local persistence of calendar and event data.""" diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 273e46040b7..cfcada03a5c 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -95,6 +95,8 @@ CONFIG_SCHEMA = vol.Schema( {vol.Optional(DOMAIN): GOOGLE_ASSISTANT_SCHEMA}, extra=vol.ALLOW_EXTRA ) +type GoogleConfigEntry = ConfigEntry[GoogleConfig] + async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate Google Actions component.""" @@ -115,7 +117,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bool: """Set up from a config entry.""" config: ConfigType = {**hass.data[DOMAIN][DATA_CONFIG]} @@ -141,7 +143,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: google_config = GoogleConfig(hass, config) await google_config.async_initialize() - hass.data[DOMAIN][entry.entry_id] = google_config + entry.runtime_data = google_config hass.http.register_view(GoogleAssistantView(google_config)) diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py index 58560d7b8d1..00d809a851c 100644 --- a/homeassistant/components/google_assistant/button.py +++ b/homeassistant/components/google_assistant/button.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant import config_entries from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -11,18 +10,19 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType +from . import GoogleConfigEntry from .const import CONF_PROJECT_ID, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN from .http import GoogleConfig async def async_setup_entry( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: GoogleConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform.""" yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] - google_config: GoogleConfig = hass.data[DOMAIN][config_entry.entry_id] + google_config = config_entry.runtime_data entities = [] diff --git a/homeassistant/components/google_assistant/diagnostics.py b/homeassistant/components/google_assistant/diagnostics.py index 48902147b05..5121a68f35c 100644 --- a/homeassistant/components/google_assistant/diagnostics.py +++ b/homeassistant/components/google_assistant/diagnostics.py @@ -5,13 +5,12 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import GoogleConfigEntry from .const import CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN -from .http import GoogleConfig from .smart_home import ( async_devices_query_response, async_devices_sync_response, @@ -29,12 +28,11 @@ TO_REDACT = [ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: GoogleConfigEntry ) -> dict[str, Any]: """Return diagnostic information.""" - data = hass.data[DOMAIN] - config: GoogleConfig = data[entry.entry_id] - yaml_config: ConfigType = data[DATA_CONFIG] + config = entry.runtime_data + yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] devices = await async_devices_sync_response(hass, config, REDACTED) sync = create_sync_response(REDACTED, devices) query = await async_devices_query_response(hass, config, devices) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index a08d7554516..94b0e0b8a25 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -10,7 +10,6 @@ from google.oauth2.credentials import Credentials import voluptuous as vol from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform from homeassistant.core import ( HomeAssistant, @@ -26,15 +25,11 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_LANGUAGE_CODE, - DATA_MEM_STORAGE, - DATA_SESSION, - DOMAIN, - SUPPORTED_LANGUAGE_CODES, -) +from .const import CONF_LANGUAGE_CODE, DOMAIN, SUPPORTED_LANGUAGE_CODES from .helpers import ( GoogleAssistantSDKAudioView, + GoogleAssistantSDKConfigEntry, + GoogleAssistantSDKRuntimeData, InMemoryStorage, async_send_text_commands, best_matching_language_code, @@ -66,10 +61,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry +) -> bool: """Set up Google Assistant SDK from a config entry.""" - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {} - implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) try: @@ -82,23 +77,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - hass.data[DOMAIN][entry.entry_id][DATA_SESSION] = session mem_storage = InMemoryStorage(hass) - hass.data[DOMAIN][entry.entry_id][DATA_MEM_STORAGE] = mem_storage hass.http.register_view(GoogleAssistantSDKAudioView(mem_storage)) await async_setup_service(hass) + entry.runtime_data = GoogleAssistantSDKRuntimeData( + session=session, mem_storage=mem_storage + ) agent = GoogleAssistantConversationAgent(hass, entry) conversation.async_set_agent(hass, entry, agent) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry +) -> bool: """Unload a config entry.""" - hass.data[DOMAIN].pop(entry.entry_id) if not hass.config_entries.async_loaded_entries(DOMAIN): for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) @@ -141,7 +138,9 @@ async def async_setup_service(hass: HomeAssistant) -> None: class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): """Google Assistant SDK conversation agent.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry + ) -> None: """Initialize the agent.""" self.hass = hass self.entry = entry @@ -161,7 +160,7 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): if self.session: session = self.session else: - session = self.hass.data[DOMAIN][self.entry.entry_id][DATA_SESSION] + session = self.entry.runtime_data.session self.session = session if not session.valid_token: await session.async_ensure_token_valid() diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index 48c92832483..6c010d39c43 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -8,17 +8,12 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow from .const import CONF_LANGUAGE_CODE, DEFAULT_NAME, DOMAIN, SUPPORTED_LANGUAGE_CODES -from .helpers import default_language_code +from .helpers import GoogleAssistantSDKConfigEntry, default_language_code _LOGGER = logging.getLogger(__name__) @@ -77,7 +72,7 @@ class OAuth2FlowHandler( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: GoogleAssistantSDKConfigEntry, ) -> OptionsFlow: """Create the options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/google_assistant_sdk/const.py b/homeassistant/components/google_assistant_sdk/const.py index 4059f006d4b..2ad5bbbfec8 100644 --- a/homeassistant/components/google_assistant_sdk/const.py +++ b/homeassistant/components/google_assistant_sdk/const.py @@ -8,9 +8,6 @@ DEFAULT_NAME: Final = "Google Assistant SDK" CONF_LANGUAGE_CODE: Final = "language_code" -DATA_MEM_STORAGE: Final = "mem_storage" -DATA_SESSION: Final = "session" - # https://developers.google.com/assistant/sdk/reference/rpc/languages SUPPORTED_LANGUAGE_CODES: Final = [ "de-DE", diff --git a/homeassistant/components/google_assistant_sdk/diagnostics.py b/homeassistant/components/google_assistant_sdk/diagnostics.py index eacded4e2e6..45600f5010e 100644 --- a/homeassistant/components/google_assistant_sdk/diagnostics.py +++ b/homeassistant/components/google_assistant_sdk/diagnostics.py @@ -5,14 +5,15 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from .helpers import GoogleAssistantSDKConfigEntry + TO_REDACT = {"access_token", "refresh_token"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return async_redact_data( diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index f9d332cd735..b319e1e432c 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -12,6 +12,7 @@ import aiohttp from aiohttp import web from gassist_text import TextAssistant from google.oauth2.credentials import Credentials +from grpc import RpcError from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import ( @@ -25,16 +26,11 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.event import async_call_later -from .const import ( - CONF_LANGUAGE_CODE, - DATA_MEM_STORAGE, - DATA_SESSION, - DOMAIN, - SUPPORTED_LANGUAGE_CODES, -) +from .const import CONF_LANGUAGE_CODE, DOMAIN, SUPPORTED_LANGUAGE_CODES _LOGGER = logging.getLogger(__name__) @@ -49,6 +45,16 @@ DEFAULT_LANGUAGE_CODES = { "pt": "pt-BR", } +type GoogleAssistantSDKConfigEntry = ConfigEntry[GoogleAssistantSDKRuntimeData] + + +@dataclass +class GoogleAssistantSDKRuntimeData: + """Runtime data for Google Assistant SDK.""" + + session: OAuth2Session + mem_storage: InMemoryStorage + @dataclass class CommandResponse: @@ -62,9 +68,9 @@ async def async_send_text_commands( ) -> list[CommandResponse]: """Send text commands to Google Assistant Service.""" # There can only be 1 entry (config_flow has single_instance_allowed) - entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + entry: GoogleAssistantSDKConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - session: OAuth2Session = hass.data[DOMAIN][entry.entry_id][DATA_SESSION] + session = entry.runtime_data.session try: await session.async_ensure_token_valid() except aiohttp.ClientResponseError as err: @@ -79,16 +85,25 @@ async def async_send_text_commands( ) as assistant: command_response_list = [] for command in commands: - resp = await hass.async_add_executor_job(assistant.assist, command) + try: + resp = await hass.async_add_executor_job(assistant.assist, command) + except RpcError as err: + _LOGGER.error( + "Failed to send command '%s' to Google Assistant: %s", + command, + err, + ) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="grpc_error" + ) from err text_response = resp[0] _LOGGER.debug("command: %s\nresponse: %s", command, text_response) audio_response = resp[2] if media_players and audio_response: - mem_storage: InMemoryStorage = hass.data[DOMAIN][entry.entry_id][ - DATA_MEM_STORAGE - ] audio_url = GoogleAssistantSDKAudioView.url.format( - filename=mem_storage.store_and_get_identifier(audio_response) + filename=entry.runtime_data.mem_storage.store_and_get_identifier( + audio_response + ) ) await hass.services.async_call( DOMAIN_MP, diff --git a/homeassistant/components/google_assistant_sdk/notify.py b/homeassistant/components/google_assistant_sdk/notify.py index ffe34eefdfd..067f222ca50 100644 --- a/homeassistant/components/google_assistant_sdk/notify.py +++ b/homeassistant/components/google_assistant_sdk/notify.py @@ -5,12 +5,15 @@ from __future__ import annotations from typing import Any from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_LANGUAGE_CODE, DOMAIN -from .helpers import async_send_text_commands, default_language_code +from .helpers import ( + GoogleAssistantSDKConfigEntry, + async_send_text_commands, + default_language_code, +) # https://support.google.com/assistant/answer/9071582?hl=en LANG_TO_BROADCAST_COMMAND = { @@ -59,7 +62,9 @@ class BroadcastNotificationService(BaseNotificationService): return # There can only be 1 entry (config_flow has single_instance_allowed) - entry: ConfigEntry = self.hass.config_entries.async_entries(DOMAIN)[0] + entry: GoogleAssistantSDKConfigEntry = self.hass.config_entries.async_entries( + DOMAIN + )[0] language_code = entry.options.get( CONF_LANGUAGE_CODE, default_language_code(self.hass) ) diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 87c93023900..885ff0aad71 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -57,5 +57,10 @@ } } } + }, + "exceptions": { + "grpc_error": { + "message": "Failed to communicate with Google Assistant" + } } } diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index c642bfd94e6..c466101e7e4 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -3,16 +3,17 @@ from __future__ import annotations import codecs -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable from dataclasses import replace from typing import Any, Literal, cast -from google.genai.errors import APIError +from google.genai.errors import APIError, ClientError from google.genai.types import ( AutomaticFunctionCallingConfig, Content, FunctionDeclaration, GenerateContentConfig, + GenerateContentResponse, GoogleSearch, HarmCategory, Part, @@ -233,6 +234,81 @@ def _convert_content( return Content(role="model", parts=parts) +async def _transform_stream( + result: AsyncGenerator[GenerateContentResponse], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + new_message = True + try: + async for response in result: + LOGGER.debug("Received response chunk: %s", response) + chunk: conversation.AssistantContentDeltaDict = {} + + if new_message: + chunk["role"] = "assistant" + new_message = False + + # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. + if response.prompt_feedback or not response.candidates: + reason = ( + response.prompt_feedback.block_reason_message + if response.prompt_feedback + else "unknown" + ) + raise HomeAssistantError( + f"The message got blocked due to content violations, reason: {reason}" + ) + + candidate = response.candidates[0] + + if ( + candidate.finish_reason is not None + and candidate.finish_reason != "STOP" + ): + # The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason + LOGGER.error( + "Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason", + candidate.finish_reason, + ) + raise HomeAssistantError( + f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}" + ) + + response_parts = ( + candidate.content.parts + if candidate.content is not None and candidate.content.parts is not None + else [] + ) + + content = "".join([part.text for part in response_parts if part.text]) + tool_calls = [] + for part in response_parts: + if not part.function_call: + continue + tool_call = part.function_call + tool_name = tool_call.name if tool_call.name else "" + tool_args = _escape_decode(tool_call.args) + tool_calls.append( + llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + ) + + if tool_calls: + chunk["tool_calls"] = tool_calls + + chunk["content"] = content + yield chunk + except ( + APIError, + ValueError, + ) as err: + LOGGER.error("Error sending message: %s %s", type(err), err) + if isinstance(err, APIError): + message = err.message + else: + message = type(err).__name__ + error = f"{ERROR_GETTING_RESPONSE}: {message}" + raise HomeAssistantError(error) from err + + class GoogleGenerativeAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -240,6 +316,7 @@ class GoogleGenerativeAIConversationEntity( _attr_has_entity_name = True _attr_name = None + _attr_supports_streaming = True def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" @@ -426,80 +503,40 @@ class GoogleGenerativeAIConversationEntity( # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: - chat_response = await chat.send_message(message=chat_request) - - if chat_response.prompt_feedback: - raise HomeAssistantError( - f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}" - ) - if not chat_response.candidates: - LOGGER.error( - "No candidates found in the response: %s", - chat_response, - ) - raise HomeAssistantError(ERROR_GETTING_RESPONSE) - + chat_response_generator = await chat.send_message_stream( + message=chat_request + ) except ( APIError, + ClientError, ValueError, ) as err: LOGGER.error("Error sending message: %s %s", type(err), err) - error = f"Sorry, I had a problem talking to Google Generative AI: {err}" + error = ERROR_GETTING_RESPONSE raise HomeAssistantError(error) from err - if (usage_metadata := chat_response.usage_metadata) is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": usage_metadata.prompt_token_count, - "cached_input_tokens": usage_metadata.cached_content_token_count - or 0, - "output_tokens": usage_metadata.candidates_token_count, - } - } - ) - - response_parts = chat_response.candidates[0].content.parts - if not response_parts: - raise HomeAssistantError(ERROR_GETTING_RESPONSE) - content = " ".join( - [part.text.strip() for part in response_parts if part.text] - ) - - tool_calls = [] - for part in response_parts: - if not part.function_call: - continue - tool_call = part.function_call - tool_name = tool_call.name - tool_args = _escape_decode(tool_call.args) - tool_calls.append( - llm.ToolInput( - tool_name=self._fix_tool_name(tool_name), - tool_args=tool_args, - ) - ) - chat_request = _create_google_tool_response_parts( [ - tool_response - async for tool_response in chat_log.async_add_assistant_content( - conversation.AssistantContent( - agent_id=user_input.agent_id, - content=content, - tool_calls=tool_calls or None, - ) + content + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, + _transform_stream(chat_response_generator), ) + if isinstance(content, conversation.ToolResultContent) ] ) - if not tool_calls: + if not chat_log.unresponded_tool_results: break response = intent.IntentResponse(language=user_input.language) - response.async_set_speech( - " ".join([part.text.strip() for part in response_parts if part.text]) - ) + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + LOGGER.error( + "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + chat_log.content[-1], + ) + raise HomeAssistantError(f"{ERROR_GETTING_RESPONSE}") + response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( response=response, conversation_id=chat_log.conversation_id, diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 24ea29aef03..9e07fdefe9d 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -50,7 +50,12 @@ from .const import ( UNITS_IMPERIAL, UNITS_METRIC, ) -from .helpers import InvalidApiKeyException, UnknownException, validate_config_entry +from .helpers import ( + InvalidApiKeyException, + PermissionDeniedException, + UnknownException, + validate_config_entry, +) RECONFIGURE_SCHEMA = vol.Schema( { @@ -188,6 +193,8 @@ async def validate_input( user_input[CONF_ORIGIN], user_input[CONF_DESTINATION], ) + except PermissionDeniedException: + return {"base": "permission_denied"} except InvalidApiKeyException: return {"base": "invalid_auth"} except TimeoutError: diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index 49294455a49..70f9300c92f 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -7,6 +7,7 @@ from google.api_core.exceptions import ( Forbidden, GatewayTimeout, GoogleAPIError, + PermissionDenied, Unauthorized, ) from google.maps.routing_v2 import ( @@ -19,10 +20,18 @@ from google.maps.routing_v2 import ( from google.type import latlng_pb2 import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.location import find_coordinates +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) @@ -37,7 +46,7 @@ def convert_to_waypoint(hass: HomeAssistant, location: str) -> Waypoint | None: try: formatted_coordinates = coordinates.split(",") vol.Schema(cv.gps(formatted_coordinates)) - except (AttributeError, vol.ExactSequenceInvalid): + except (AttributeError, vol.Invalid): return Waypoint(address=location) return Waypoint( location=Location( @@ -67,6 +76,9 @@ async def validate_config_entry( await client.compute_routes( request, metadata=[("x-goog-fieldmask", field_mask)] ) + except PermissionDenied as permission_error: + _LOGGER.error("Permission denied: %s", permission_error.message) + raise PermissionDeniedException from permission_error except (Unauthorized, Forbidden) as unauthorized_error: _LOGGER.error("Request denied: %s", unauthorized_error.message) raise InvalidApiKeyException from unauthorized_error @@ -84,3 +96,30 @@ class InvalidApiKeyException(Exception): class UnknownException(Exception): """Unknown API Error.""" + + +class PermissionDeniedException(Exception): + """Permission Denied Error.""" + + +def create_routes_api_disabled_issue(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Create an issue for the Routes API being disabled.""" + async_create_issue( + hass, + DOMAIN, + f"routes_api_disabled_{entry.entry_id}", + learn_more_url="https://www.home-assistant.io/integrations/google_travel_time#setup", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="routes_api_disabled", + translation_placeholders={ + "entry_title": entry.title, + "enable_api_url": "https://cloud.google.com/endpoints/docs/openapi/enable-api", + "api_key_restrictions_url": "https://cloud.google.com/docs/authentication/api-keys#adding-api-restrictions", + }, + ) + + +def delete_routes_api_disabled_issue(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Delete the issue for the Routes API being disabled.""" + async_delete_issue(hass, DOMAIN, f"routes_api_disabled_{entry.entry_id}") diff --git a/homeassistant/components/google_travel_time/manifest.json b/homeassistant/components/google_travel_time/manifest.json index 6d69c908d59..74c015c5345 100644 --- a/homeassistant/components/google_travel_time/manifest.json +++ b/homeassistant/components/google_travel_time/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_travel_time", "iot_class": "cloud_polling", "loggers": ["google", "homeassistant.helpers.location"], - "requirements": ["google-maps-routing==0.6.14"] + "requirements": ["google-maps-routing==0.6.15"] } diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 7448fc1cb09..1a9b361bd33 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -7,7 +7,7 @@ import logging from typing import TYPE_CHECKING, Any from google.api_core.client_options import ClientOptions -from google.api_core.exceptions import GoogleAPIError +from google.api_core.exceptions import GoogleAPIError, PermissionDenied from google.maps.routing_v2 import ( ComputeRoutesRequest, Route, @@ -58,7 +58,11 @@ from .const import ( TRAVEL_MODES_TO_GOOGLE_SDK_ENUM, UNITS_TO_GOOGLE_SDK_ENUM, ) -from .helpers import convert_to_waypoint +from .helpers import ( + convert_to_waypoint, + create_routes_api_disabled_issue, + delete_routes_api_disabled_issue, +) _LOGGER = logging.getLogger(__name__) @@ -271,8 +275,14 @@ class GoogleTravelTimeSensor(SensorEntity): response = await self._client.compute_routes( request, metadata=[("x-goog-fieldmask", FIELD_MASK)] ) + _LOGGER.debug("Received response: %s", response) if response is not None and len(response.routes) > 0: self._route = response.routes[0] + delete_routes_api_disabled_issue(self.hass, self._config_entry) + except PermissionDenied: + _LOGGER.error("Routes API is disabled for this API key") + create_routes_api_disabled_issue(self.hass, self._config_entry) + self._route = None except GoogleAPIError as ex: _LOGGER.error("Error getting travel time: %s", ex) self._route = None diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 87bc09eb456..f46d33fda09 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -21,6 +21,7 @@ } }, "error": { + "permission_denied": "The Routes API is not enabled for this API key. Please see the setup instructions for detailed information.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" @@ -100,5 +101,11 @@ "fewer_transfers": "Fewer transfers" } } + }, + "issues": { + "routes_api_disabled": { + "title": "The Routes API must be enabled", + "description": "Your Google Travel Time integration `{entry_title}` uses an API key which does not have the Routes API enabled.\n\n Please follow the instructions to [enable the API for your project]({enable_api_url}) and make sure your [API key restrictions]({api_key_restrictions_url}) allow access to the Routes API.\n\n After enabling the API this issue will be resolved automatically." + } } } diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 7c7612ed201..37493ed24fa 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -24,6 +24,8 @@ from .const import ( DOMAIN, ) +type GPSLoggerConfigEntry = ConfigEntry[set[str]] + PLATFORMS = [Platform.DEVICE_TRACKER] TRACKER_UPDATE = f"{DOMAIN}_tracker_update" @@ -88,9 +90,9 @@ async def handle_webhook( return web.Response(text=f"Setting location for {device}") -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GPSLoggerConfigEntry) -> bool: """Configure based on config entry.""" - hass.data.setdefault(DOMAIN, {"devices": set(), "unsub_device_tracker": {}}) + entry.runtime_data = set() webhook.async_register( hass, DOMAIN, "GPSLogger", entry.data[CONF_WEBHOOK_ID], handle_webhook ) @@ -103,7 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - hass.data[DOMAIN]["unsub_device_tracker"].pop(entry.entry_id)() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index be38382098d..950aa2a2638 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -1,7 +1,6 @@ """Support for the GPSLogger device tracking.""" from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_GPS_ACCURACY, @@ -15,19 +14,20 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE +from . import TRACKER_UPDATE, GPSLoggerConfigEntry from .const import ( ATTR_ACTIVITY, ATTR_ALTITUDE, ATTR_DIRECTION, ATTR_PROVIDER, ATTR_SPEED, + DOMAIN, ) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GPSLoggerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure a dispatcher connection based on a config entry.""" @@ -35,16 +35,14 @@ async def async_setup_entry( @callback def _receive_data(device, gps, battery, accuracy, attrs): """Receive set location.""" - if device in hass.data[GPL_DOMAIN]["devices"]: + if device in entry.runtime_data: return - hass.data[GPL_DOMAIN]["devices"].add(device) + entry.runtime_data.add(device) async_add_entities([GPSLoggerEntity(device, gps, battery, accuracy, attrs)]) - hass.data[GPL_DOMAIN]["unsub_device_tracker"][entry.entry_id] = ( - async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) - ) + entry.async_on_unload(async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data)) # Restore previously loaded devices dev_reg = dr.async_get(hass) @@ -58,7 +56,7 @@ async def async_setup_entry( entities = [] for dev_id in dev_ids: - hass.data[GPL_DOMAIN]["devices"].add(dev_id) + entry.runtime_data.add(dev_id) entity = GPSLoggerEntity(dev_id, None, None, None, None) entities.append(entity) @@ -83,7 +81,7 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): self._unsub_dispatcher = None self._attr_unique_id = device self._attr_device_info = DeviceInfo( - identifiers={(GPL_DOMAIN, device)}, + identifiers={(DOMAIN, device)}, name=device, ) diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index 7cb4f0f0921..2b5a38082fc 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -1,33 +1,29 @@ """The Gree Climate integration.""" +from __future__ import annotations + from datetime import timedelta import logging from homeassistant.components.network import async_get_ipv4_broadcast_addresses -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_time_interval -from .const import ( - COORDINATORS, - DATA_DISCOVERY_SERVICE, - DISCOVERY_SCAN_INTERVAL, - DISPATCHERS, - DOMAIN, -) -from .coordinator import DiscoveryService +from .const import DISCOVERY_SCAN_INTERVAL +from .coordinator import DiscoveryService, GreeConfigEntry, GreeRuntimeData _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: """Set up Gree Climate from a config entry.""" - hass.data.setdefault(DOMAIN, {}) gree_discovery = DiscoveryService(hass, entry) - hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery + entry.runtime_data = GreeRuntimeData( + discovery_service=gree_discovery, coordinators=[] + ) async def _async_scan_update(_=None): bcast_addr = list(await async_get_ipv4_broadcast_addresses(hass)) @@ -47,15 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: """Unload a config entry.""" - if hass.data.get(DATA_DISCOVERY_SERVICE) is not None: - hass.data.pop(DATA_DISCOVERY_SERVICE) - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN].pop(COORDINATORS, None) - hass.data[DOMAIN].pop(DISPATCHERS, None) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index f703ded1ea2..e3549973f43 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -36,21 +36,18 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - COORDINATORS, DISPATCH_DEVICE_DISCOVERED, - DOMAIN, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, TARGET_TEMPERATURE_STEP, ) -from .coordinator import DeviceDataUpdateCoordinator +from .coordinator import DeviceDataUpdateCoordinator, GreeConfigEntry from .entity import GreeEntity _LOGGER = logging.getLogger(__name__) @@ -87,17 +84,17 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GreeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @callback - def init_device(coordinator): + def init_device(coordinator: DeviceDataUpdateCoordinator) -> None: """Register the device.""" async_add_entities([GreeClimateEntity(coordinator)]) - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in entry.runtime_data.coordinators: init_device(coordinator) entry.async_on_unload( diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index 14236f09fa2..6c1f8f954c9 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -1,16 +1,10 @@ """Constants for the Gree Climate integration.""" -COORDINATORS = "coordinators" - -DATA_DISCOVERY_SERVICE = "gree_discovery" - DISCOVERY_SCAN_INTERVAL = 300 DISCOVERY_TIMEOUT = 8 DISPATCH_DEVICE_DISCOVERED = "gree_device_discovered" -DISPATCHERS = "dispatchers" DOMAIN = "gree" -COORDINATOR = "coordinator" FAN_MEDIUM_LOW = "medium low" FAN_MEDIUM_HIGH = "medium high" diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index c8b4e6cff54..0d697398fc0 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import Any @@ -20,7 +21,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from homeassistant.util.dt import utcnow from .const import ( - COORDINATORS, DISCOVERY_TIMEOUT, DISPATCH_DEVICE_DISCOVERED, DOMAIN, @@ -31,14 +31,24 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type GreeConfigEntry = ConfigEntry[GreeRuntimeData] + + +@dataclass +class GreeRuntimeData: + """RUntime data for Gree Climate integration.""" + + discovery_service: DiscoveryService + coordinators: list[DeviceDataUpdateCoordinator] + class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Manages polling for state changes from the device.""" - config_entry: ConfigEntry + config_entry: GreeConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: Device + self, hass: HomeAssistant, config_entry: GreeConfigEntry, device: Device ) -> None: """Initialize the data update coordinator.""" super().__init__( @@ -128,7 +138,7 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): class DiscoveryService(Listener): """Discovery event handler for gree devices.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: GreeConfigEntry) -> None: """Initialize discovery service.""" super().__init__() self.hass = hass @@ -137,8 +147,6 @@ class DiscoveryService(Listener): self.discovery = Discovery(DISCOVERY_TIMEOUT) self.discovery.add_listener(self) - hass.data[DOMAIN].setdefault(COORDINATORS, []) - async def device_found(self, device_info: DeviceInfo) -> None: """Handle new device found on the network.""" @@ -157,14 +165,14 @@ class DiscoveryService(Listener): device.device_info.port, ) coordo = DeviceDataUpdateCoordinator(self.hass, self.entry, device) - self.hass.data[DOMAIN][COORDINATORS].append(coordo) + self.entry.runtime_data.coordinators.append(coordo) await coordo.async_refresh() async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo) async def device_update(self, device_info: DeviceInfo) -> None: """Handle updates in device information, update if ip has changed.""" - for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + for coordinator in self.entry.runtime_data.coordinators: if coordinator.device.device_info.mac == device_info.mac: coordinator.device.device_info.ip = device_info.ip await coordinator.async_refresh() diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 67dc10138d1..ab138ea3be6 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -13,13 +13,13 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -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 COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN -from .entity import GreeEntity +from .const import DISPATCH_DEVICE_DISCOVERED +from .coordinator import GreeConfigEntry +from .entity import DeviceDataUpdateCoordinator, GreeEntity @dataclass(kw_only=True, frozen=True) @@ -92,13 +92,13 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GreeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @callback - def init_device(coordinator): + def init_device(coordinator: DeviceDataUpdateCoordinator) -> None: """Register the device.""" async_add_entities( @@ -106,7 +106,7 @@ async def async_setup_entry( for description in GREE_SWITCHES ) - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in entry.runtime_data.coordinators: init_device(coordinator) entry.async_on_unload( diff --git a/homeassistant/components/greeneye_monitor/const.py b/homeassistant/components/greeneye_monitor/const.py index 40236b3219f..02c6d9845b0 100644 --- a/homeassistant/components/greeneye_monitor/const.py +++ b/homeassistant/components/greeneye_monitor/const.py @@ -1,5 +1,14 @@ """Shared constants for the greeneye_monitor integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from greeneye import Monitors + CONF_CHANNELS = "channels" CONF_COUNTED_QUANTITY = "counted_quantity" CONF_COUNTED_QUANTITY_PER_PULSE = "counted_quantity_per_pulse" @@ -13,8 +22,8 @@ CONF_TEMPERATURE_SENSORS = "temperature_sensors" CONF_TIME_UNIT = "time_unit" CONF_VOLTAGE_SENSORS = "voltage" -DATA_GREENEYE_MONITOR = "greeneye_monitor" DOMAIN = "greeneye_monitor" +DATA_GREENEYE_MONITOR: HassKey[Monitors] = HassKey(DOMAIN) SENSOR_TYPE_CURRENT = "current_sensor" SENSOR_TYPE_PULSE_COUNTER = "pulse_counter" diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 04464fe2567..7cfc0e40fc0 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -109,7 +109,7 @@ async def async_setup_platform( if len(monitor_configs) == 0: monitors.remove_listener(on_new_monitor) - monitors: greeneye.Monitors = hass.data[DATA_GREENEYE_MONITOR] + monitors = hass.data[DATA_GREENEYE_MONITOR] monitors.add_listener(on_new_monitor) for monitor in monitors.monitors.values(): on_new_monitor(monitor) diff --git a/homeassistant/components/gstreamer/__init__.py b/homeassistant/components/gstreamer/__init__.py index 9fb97d25744..d24ac28f25f 100644 --- a/homeassistant/components/gstreamer/__init__.py +++ b/homeassistant/components/gstreamer/__init__.py @@ -1 +1,3 @@ """The gstreamer component.""" + +DOMAIN = "gstreamer" diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index bb78aff8faf..7d830377f1b 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -19,16 +19,18 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) CONF_PIPELINE = "pipeline" -DOMAIN = "gstreamer" PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PIPELINE): cv.string} @@ -48,6 +50,20 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Gstreamer platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "GStreamer", + }, + ) name = config.get(CONF_NAME) pipeline = config.get(CONF_PIPELINE) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 075c388c4e4..65f5525d587 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -3,28 +3,16 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any from aioguardian import Client -from aioguardian.errors import GuardianError -import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_DEVICE_ID, - CONF_FILENAME, - CONF_IP_ADDRESS, - CONF_PORT, - CONF_URL, - Platform, -) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( API_SENSOR_PAIR_DUMP, @@ -39,40 +27,10 @@ from .const import ( SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .coordinator import GuardianDataUpdateCoordinator +from .services import setup_services -DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -SERVICE_NAME_PAIR_SENSOR = "pair_sensor" -SERVICE_NAME_UNPAIR_SENSOR = "unpair_sensor" -SERVICE_NAME_UPGRADE_FIRMWARE = "upgrade_firmware" - -SERVICES = ( - SERVICE_NAME_PAIR_SENSOR, - SERVICE_NAME_UNPAIR_SENSOR, - SERVICE_NAME_UPGRADE_FIRMWARE, -) - -SERVICE_BASE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - } -) - -SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Required(CONF_UID): cv.string, - } -) - -SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Optional(CONF_URL): cv.url, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_FILENAME): cv.string, - }, -) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -82,36 +40,26 @@ PLATFORMS = [ Platform.VALVE, ] +type GuardianConfigEntry = ConfigEntry[GuardianData] + @dataclass class GuardianData: - """Define an object to be stored in `hass.data`.""" + """Define an object to be stored in `entry.runtime_data`.""" - entry: ConfigEntry + entry: GuardianConfigEntry client: Client valve_controller_coordinators: dict[str, GuardianDataUpdateCoordinator] paired_sensor_manager: PairedSensorManager -@callback -def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) -> str: - """Get the entry ID related to a service call (by device ID).""" - device_id = call.data[CONF_DEVICE_ID] - device_registry = dr.async_get(hass) - - if (device_entry := device_registry.async_get(device_id)) is None: - raise ValueError(f"Invalid Guardian device ID: {device_id}") - - for entry_id in device_entry.config_entries: - if (entry := hass.config_entries.async_get_entry(entry_id)) is None: - continue - if entry.domain == DOMAIN: - return entry_id - - raise ValueError(f"No config entry for device ID: {device_id}") +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Elexa Guardian component.""" + setup_services(hass) + return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GuardianConfigEntry) -> bool: """Set up Elexa Guardian from a config entry.""" client = Client(entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT]) @@ -162,8 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await paired_sensor_manager.async_initialize() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = GuardianData( + entry.runtime_data = GuardianData( entry=entry, client=client, valve_controller_coordinators=valve_controller_coordinators, @@ -173,87 +120,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Set up all of the Guardian entity platforms: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - @callback - def call_with_data( - func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]], - ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: - """Hydrate a service call with the appropriate GuardianData object.""" - - async def wrapper(call: ServiceCall) -> None: - """Wrap the service function.""" - entry_id = async_get_entry_id_for_service_call(hass, call) - data = hass.data[DOMAIN][entry_id] - - try: - async with data.client: - await func(call, data) - except GuardianError as err: - raise HomeAssistantError( - f"Error while executing {func.__name__}: {err}" - ) from err - - return wrapper - - @call_with_data - async def async_pair_sensor(call: ServiceCall, data: GuardianData) -> None: - """Add a new paired sensor.""" - uid = call.data[CONF_UID] - await data.client.sensor.pair_sensor(uid) - await data.paired_sensor_manager.async_pair_sensor(uid) - - @call_with_data - async def async_unpair_sensor(call: ServiceCall, data: GuardianData) -> None: - """Remove a paired sensor.""" - uid = call.data[CONF_UID] - await data.client.sensor.unpair_sensor(uid) - await data.paired_sensor_manager.async_unpair_sensor(uid) - - @call_with_data - async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None: - """Upgrade the device firmware.""" - await data.client.system.upgrade_firmware( - url=call.data[CONF_URL], - port=call.data[CONF_PORT], - filename=call.data[CONF_FILENAME], - ) - - for service_name, schema, method in ( - ( - SERVICE_NAME_PAIR_SENSOR, - SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, - async_pair_sensor, - ), - ( - SERVICE_NAME_UNPAIR_SENSOR, - SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, - async_unpair_sensor, - ), - ( - SERVICE_NAME_UPGRADE_FIRMWARE, - SERVICE_UPGRADE_FIRMWARE_SCHEMA, - async_upgrade_firmware, - ), - ): - if hass.services.has_service(DOMAIN, service_name): - continue - hass.services.async_register(DOMAIN, service_name, method, schema=schema) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GuardianConfigEntry) -> 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) - - if not hass.config_entries.async_loaded_entries(DOMAIN): - # If this is the last loaded instance of Guardian, deregister any services - # defined during integration setup: - for service_name in SERVICES: - hass.services.async_remove(DOMAIN, service_name) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class PairedSensorManager: @@ -262,7 +134,7 @@ class PairedSensorManager: def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, client: Client, api_lock: asyncio.Lock, sensor_pair_dump_coordinator: GuardianDataUpdateCoordinator, diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 7d5f97bdb65..d6583abd843 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -12,17 +12,15 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import GuardianData +from . import GuardianConfigEntry from .const import ( API_SYSTEM_ONBOARD_SENSOR_STATUS, CONF_UID, - DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .coordinator import GuardianDataUpdateCoordinator @@ -87,11 +85,11 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data uid = entry.data[CONF_UID] async_finish_entity_domain_replacements( @@ -151,7 +149,7 @@ class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, coordinator: GuardianDataUpdateCoordinator, description: BinarySensorEntityDescription, ) -> None: @@ -173,7 +171,7 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, coordinators: dict[str, GuardianDataUpdateCoordinator], description: ValveControllerBinarySensorDescription, ) -> None: diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index 01bac63c6e3..2ecdbed38ea 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -12,14 +12,13 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import GuardianData -from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN +from . import GuardianConfigEntry, GuardianData +from .const import API_SYSTEM_DIAGNOSTICS from .entity import ValveControllerEntity, ValveControllerEntityDescription from .util import convert_exceptions_to_homeassistant_error @@ -69,11 +68,11 @@ BUTTON_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian buttons based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( GuardianButton(entry, data, description) for description in BUTTON_DESCRIPTIONS @@ -90,7 +89,7 @@ class GuardianButton(ValveControllerEntity, ButtonEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, data: GuardianData, description: ValveControllerButtonDescription, ) -> None: diff --git a/homeassistant/components/guardian/coordinator.py b/homeassistant/components/guardian/coordinator.py index 500b7c10784..a49bf6803d9 100644 --- a/homeassistant/components/guardian/coordinator.py +++ b/homeassistant/components/guardian/coordinator.py @@ -5,18 +5,20 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine from datetime import timedelta -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from aioguardian import Client from aioguardian.errors import GuardianError -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +if TYPE_CHECKING: + from . import GuardianConfigEntry + DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" @@ -25,13 +27,13 @@ SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an extended DataUpdateCoordinator with some Guardian goodies.""" - config_entry: ConfigEntry + config_entry: GuardianConfigEntry def __init__( self, hass: HomeAssistant, *, - entry: ConfigEntry, + entry: GuardianConfigEntry, client: Client, api_name: str, api_coro: Callable[[], Coroutine[Any, Any, dict[str, Any]]], diff --git a/homeassistant/components/guardian/diagnostics.py b/homeassistant/components/guardian/diagnostics.py index 2f4287bea29..22a1bde7817 100644 --- a/homeassistant/components/guardian/diagnostics.py +++ b/homeassistant/components/guardian/diagnostics.py @@ -5,12 +5,11 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from . import GuardianData -from .const import CONF_UID, DOMAIN +from . import GuardianConfigEntry +from .const import CONF_UID CONF_BSSID = "bssid" CONF_PAIRED_UIDS = "paired_uids" @@ -29,10 +28,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: GuardianConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/guardian/entity.py b/homeassistant/components/guardian/entity.py index fca0afeda0e..c48c87afa01 100644 --- a/homeassistant/components/guardian/entity.py +++ b/homeassistant/components/guardian/entity.py @@ -4,11 +4,11 @@ from __future__ import annotations from dataclasses import dataclass -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import GuardianConfigEntry from .const import API_SYSTEM_DIAGNOSTICS, CONF_UID, DOMAIN from .coordinator import GuardianDataUpdateCoordinator @@ -32,7 +32,7 @@ class PairedSensorEntity(GuardianEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, coordinator: GuardianDataUpdateCoordinator, description: EntityDescription, ) -> None: @@ -62,7 +62,7 @@ class ValveControllerEntity(GuardianEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, coordinators: dict[str, GuardianDataUpdateCoordinator], description: ValveControllerEntityDescription, ) -> None: diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 13dd8e01296..da4a78d7b7e 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfElectricCurrent, @@ -25,13 +24,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import GuardianData +from . import GuardianConfigEntry from .const import ( API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, API_VALVE_STATUS, CONF_UID, - DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .entity import ( @@ -138,11 +136,11 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def add_new_paired_sensor(uid: str) -> None: diff --git a/homeassistant/components/guardian/services.py b/homeassistant/components/guardian/services.py new file mode 100644 index 00000000000..288c6becbee --- /dev/null +++ b/homeassistant/components/guardian/services.py @@ -0,0 +1,144 @@ +"""Support for Guardian services.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any + +from aioguardian.errors import GuardianError +import voluptuous as vol + +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_DEVICE_ID, + CONF_FILENAME, + CONF_PORT, + CONF_URL, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import CONF_UID, DOMAIN + +if TYPE_CHECKING: + from . import GuardianConfigEntry, GuardianData + +SERVICE_NAME_PAIR_SENSOR = "pair_sensor" +SERVICE_NAME_UNPAIR_SENSOR = "unpair_sensor" +SERVICE_NAME_UPGRADE_FIRMWARE = "upgrade_firmware" + +SERVICES = ( + SERVICE_NAME_PAIR_SENSOR, + SERVICE_NAME_UNPAIR_SENSOR, + SERVICE_NAME_UPGRADE_FIRMWARE, +) + +SERVICE_BASE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + } +) + +SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(CONF_UID): cv.string, + } +) + +SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Optional(CONF_URL): cv.url, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_FILENAME): cv.string, + }, +) + + +@callback +def async_get_entry_id_for_service_call(call: ServiceCall) -> GuardianConfigEntry: + """Get the entry ID related to a service call (by device ID).""" + device_id = call.data[CONF_DEVICE_ID] + device_registry = dr.async_get(call.hass) + + if (device_entry := device_registry.async_get(device_id)) is None: + raise ValueError(f"Invalid Guardian device ID: {device_id}") + + for entry_id in device_entry.config_entries: + if (entry := call.hass.config_entries.async_get_entry(entry_id)) is None: + continue + if entry.domain == DOMAIN: + return entry + + raise ValueError(f"No config entry for device ID: {device_id}") + + +@callback +def call_with_data( + func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]], +) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: + """Hydrate a service call with the appropriate GuardianData object.""" + + async def wrapper(call: ServiceCall) -> None: + """Wrap the service function.""" + data = async_get_entry_id_for_service_call(call).runtime_data + + try: + async with data.client: + await func(call, data) + except GuardianError as err: + raise HomeAssistantError( + f"Error while executing {func.__name__}: {err}" + ) from err + + return wrapper + + +@call_with_data +async def async_pair_sensor(call: ServiceCall, data: GuardianData) -> None: + """Add a new paired sensor.""" + uid = call.data[CONF_UID] + await data.client.sensor.pair_sensor(uid) + await data.paired_sensor_manager.async_pair_sensor(uid) + + +@call_with_data +async def async_unpair_sensor(call: ServiceCall, data: GuardianData) -> None: + """Remove a paired sensor.""" + uid = call.data[CONF_UID] + await data.client.sensor.unpair_sensor(uid) + await data.paired_sensor_manager.async_unpair_sensor(uid) + + +@call_with_data +async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None: + """Upgrade the device firmware.""" + await data.client.system.upgrade_firmware( + url=call.data[CONF_URL], + port=call.data[CONF_PORT], + filename=call.data[CONF_FILENAME], + ) + + +def setup_services(hass: HomeAssistant) -> None: + """Register the Renault services.""" + for service_name, schema, method in ( + ( + SERVICE_NAME_PAIR_SENSOR, + SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, + async_pair_sensor, + ), + ( + SERVICE_NAME_UNPAIR_SENSOR, + SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, + async_unpair_sensor, + ), + ( + SERVICE_NAME_UPGRADE_FIRMWARE, + SERVICE_UPGRADE_FIRMWARE_SCHEMA, + async_upgrade_firmware, + ), + ): + hass.services.async_register(DOMAIN, service_name, method, schema=schema) diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index a2c9ca282be..7640425d8c1 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -9,13 +9,12 @@ from typing import Any from aioguardian import Client from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import GuardianData -from .const import API_VALVE_STATUS, API_WIFI_STATUS, DOMAIN +from . import GuardianConfigEntry, GuardianData +from .const import API_VALVE_STATUS, API_WIFI_STATUS from .entity import ValveControllerEntity, ValveControllerEntityDescription from .util import convert_exceptions_to_homeassistant_error from .valve import GuardianValveState @@ -111,11 +110,11 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( ValveControllerSwitch(entry, data, description) @@ -130,7 +129,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, data: GuardianData, description: ValveControllerSwitchDescription, ) -> None: diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 69e79f6627e..d05b6ef98d9 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Concatenate from aioguardian.errors import GuardianError -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -18,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from .const import LOGGER if TYPE_CHECKING: + from . import GuardianConfigEntry from .entity import GuardianEntity DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) @@ -36,7 +36,7 @@ class EntityDomainReplacementStrategy: @callback def async_finish_entity_domain_replacements( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, entity_replacement_strategies: Iterable[EntityDomainReplacementStrategy], ) -> None: """Remove old entities and create a repairs issue with info on their replacement.""" diff --git a/homeassistant/components/guardian/valve.py b/homeassistant/components/guardian/valve.py index 6847b3211c5..ad8cd9cae00 100644 --- a/homeassistant/components/guardian/valve.py +++ b/homeassistant/components/guardian/valve.py @@ -15,12 +15,11 @@ from homeassistant.components.valve import ( ValveEntityDescription, ValveEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import GuardianData -from .const import API_VALVE_STATUS, DOMAIN +from . import GuardianConfigEntry, GuardianData +from .const import API_VALVE_STATUS from .entity import ValveControllerEntity, ValveControllerEntityDescription from .util import convert_exceptions_to_homeassistant_error @@ -110,11 +109,11 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( ValveControllerValve(entry, data, description) @@ -132,7 +131,7 @@ class ValveControllerValve(ValveControllerEntity, ValveEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, data: GuardianData, description: ValveControllerValveDescription, ) -> None: diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 7a5677cb687..f9874c711f0 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -1,6 +1,6 @@ """Constants for the habitica integration.""" -from homeassistant.const import APPLICATION_NAME, CONF_PATH, __version__ +from homeassistant.const import APPLICATION_NAME, __version__ CONF_API_USER = "api_user" @@ -13,15 +13,6 @@ HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png" DOMAIN = "habitica" -# service constants -SERVICE_API_CALL = "api_call" -ATTR_PATH = CONF_PATH -ATTR_ARGS = "args" - -# event constants -EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success" -ATTR_DATA = "data" - MANUFACTURER = "HabitRPG, Inc." NAME = "Habitica" diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 3c3a16f591a..d0eb60312b4 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -52,10 +52,10 @@ type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): """Habitica Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: HabiticaConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, habitica: Habitica + self, hass: HomeAssistant, config_entry: HabiticaConfigEntry, habitica: Habitica ) -> None: """Initialize the Habitica data coordinator.""" super().__init__( diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index aac90814af5..d241d3855d6 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -159,6 +159,12 @@ }, "quest_scrolls": { "default": "mdi:script-text-outline" + }, + "pending_damage": { + "default": "mdi:sword" + }, + "pending_quest_items": { + "default": "mdi:sack" } }, "switch": { diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 48b6997239e..8b03e5efe01 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.3.7"] + "requirements": ["habiticalib==0.4.0"] } diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index e715dd6d07b..5b64d0d8119 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -40,7 +40,13 @@ from homeassistant.util import dt as dt_util from .const import ASSETS_URL, DOMAIN from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator from .entity import HabiticaBase -from .util import get_attribute_points, get_attributes_total, inventory_list +from .util import ( + get_attribute_points, + get_attributes_total, + inventory_list, + pending_damage, + pending_quest_items, +) _LOGGER = logging.getLogger(__name__) @@ -99,6 +105,8 @@ class HabiticaSensorEntity(StrEnum): FOOD_TOTAL = "food_total" SADDLE = "saddle" QUEST_SCROLLS = "quest_scrolls" + PENDING_DAMAGE = "pending_damage" + PENDING_QUEST_ITEMS = "pending_quest_items" SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( @@ -263,6 +271,18 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( entity_picture="inventory_quest_scroll_dustbunnies.png", attributes_fn=lambda user, content: inventory_list(user, content, "quests"), ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.PENDING_DAMAGE, + translation_key=HabiticaSensorEntity.PENDING_DAMAGE, + value_fn=pending_damage, + suggested_display_precision=1, + entity_picture=ha.DAMAGE, + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.PENDING_QUEST_ITEMS, + translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS, + value_fn=pending_quest_items, + ), ) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index bcbd6caa7a7..8ef12a38f1c 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -29,7 +29,7 @@ import voluptuous as vol from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DATE, ATTR_NAME, CONF_NAME +from homeassistant.const import ATTR_DATE, ATTR_NAME from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -38,28 +38,24 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.selector import ConfigEntrySelector from homeassistant.util import dt as dt_util from .const import ( ATTR_ADD_CHECKLIST_ITEM, ATTR_ALIAS, - ATTR_ARGS, ATTR_CLEAR_DATE, ATTR_CLEAR_REMINDER, ATTR_CONFIG_ENTRY, ATTR_COST, ATTR_COUNTER_DOWN, ATTR_COUNTER_UP, - ATTR_DATA, ATTR_DIRECTION, ATTR_FREQUENCY, ATTR_INTERVAL, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, - ATTR_PATH, ATTR_PRIORITY, ATTR_REMINDER, ATTR_REMOVE_CHECKLIST_ITEM, @@ -78,10 +74,8 @@ from .const import ( ATTR_UNSCORE_CHECKLIST_ITEM, ATTR_UP_DOWN, DOMAIN, - EVENT_API_CALL_SUCCESS, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, - SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, SERVICE_CREATE_DAILY, @@ -106,14 +100,6 @@ from .coordinator import HabiticaConfigEntry _LOGGER = logging.getLogger(__name__) -SERVICE_API_CALL_SCHEMA = vol.Schema( - { - vol.Required(ATTR_NAME): str, - vol.Required(ATTR_PATH): vol.All(cv.ensure_list, [str]), - vol.Optional(ATTR_ARGS): dict, - } -) - SERVICE_CAST_SKILL_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -266,46 +252,6 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Set up services for Habitica integration.""" - async def handle_api_call(call: ServiceCall) -> None: - async_create_issue( - hass, - DOMAIN, - "deprecated_api_call", - breaks_in_ha_version="2025.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_api_call", - ) - _LOGGER.warning( - "Deprecated action called: 'habitica.api_call' is deprecated and will be removed in Home Assistant version 2025.6.0" - ) - - name = call.data[ATTR_NAME] - path = call.data[ATTR_PATH] - entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN) - - api = None - for entry in entries: - if entry.data[CONF_NAME] == name: - api = await entry.runtime_data.habitica.habitipy() - break - if api is None: - _LOGGER.error("API_CALL: User '%s' not configured", name) - return - try: - for element in path: - api = api[element] - except KeyError: - _LOGGER.error( - "API_CALL: Path %s is invalid for API on '{%s}' element", path, element - ) - return - kwargs = call.data.get(ATTR_ARGS, {}) - data = await api(**kwargs) - hass.bus.async_fire( - EVENT_API_CALL_SUCCESS, {ATTR_NAME: name, ATTR_PATH: path, ATTR_DATA: data} - ) - async def cast_skill(call: ServiceCall) -> ServiceResponse: """Skill action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) @@ -928,12 +874,6 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_CREATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) - hass.services.async_register( - DOMAIN, - SERVICE_API_CALL, - handle_api_call, - schema=SERVICE_API_CALL_SCHEMA, - ) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 3fb25e2b4b7..e7f4b4207b0 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -1,20 +1,4 @@ # Describes the format for Habitica service -api_call: - fields: - name: - required: true - example: "xxxNotAValidNickxxx" - selector: - text: - path: - required: true - example: '["tasks", "user", "post"]' - selector: - object: - args: - example: '{"text": "Use API from Home Assistant", "type": "todo"}' - selector: - object: cast_skill: fields: config_entry: &config_entry diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 695eb1576fe..22bc79555e8 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -426,6 +426,14 @@ "quest_scrolls": { "name": "Quest scrolls", "unit_of_measurement": "scrolls" + }, + "pending_damage": { + "name": "Pending damage", + "unit_of_measurement": "damage" + }, + "pending_quest_items": { + "name": "Pending quest items", + "unit_of_measurement": "items" } }, "switch": { @@ -526,31 +534,9 @@ "deprecated_entity": { "title": "The Habitica {name} entity is deprecated", "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue." - }, - "deprecated_api_call": { - "title": "The Habitica action habitica.api_call is deprecated", - "description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities." } }, "services": { - "api_call": { - "name": "API name", - "description": "Calls Habitica API.", - "fields": { - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "Habitica's username to call for." - }, - "path": { - "name": "[%key:common::config_flow::data::path%]", - "description": "Items from API URL in form of an array with method attached at the end. Consult https://habitica.com/apidoc/. Example uses https://habitica.com/apidoc/#api-Task-CreateUserTasks." - }, - "args": { - "name": "Args", - "description": "Any additional JSON or URL parameter arguments. See apidoc mentioned for path. Example uses same API endpoint." - } - } - }, "cast_skill": { "name": "Cast a skill", "description": "Uses a skill or spell from your Habitica character on a specific task to affect its progress or status.", diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 1ca908eb3ff..9ef0b8cbadd 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -162,3 +162,25 @@ def inventory_list( for k, v in getattr(user.items, item_type, {}).items() if k != "Saddle" } + + +def pending_quest_items(user: UserData, content: ContentData) -> int | None: + """Pending quest items.""" + + return ( + user.party.quest.progress.collectedItems + if user.party.quest.key + and content.quests[user.party.quest.key].collect is not None + else None + ) + + +def pending_damage(user: UserData, content: ContentData) -> float | None: + """Pending damage.""" + + return ( + user.party.quest.progress.up + if user.party.quest.key + and content.quests[user.party.quest.key].boss is not None + else None + ) diff --git a/homeassistant/components/hardware/__init__.py b/homeassistant/components/hardware/__init__.py index 9de281b1e50..5db9671a4ed 100644 --- a/homeassistant/components/hardware/__init__.py +++ b/homeassistant/components/hardware/__init__.py @@ -2,19 +2,31 @@ from __future__ import annotations +import psutil_home_assistant as ha_psutil + from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api -from .const import DOMAIN +from .const import DATA_HARDWARE, DOMAIN +from .hardware import async_process_hardware_platforms +from .models import HardwareData, SystemStatus CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Hardware.""" - hass.data[DOMAIN] = {} + hass.data[DATA_HARDWARE] = HardwareData( + hardware_platform={}, + system_status=SystemStatus( + ha_psutil=await hass.async_add_executor_job(ha_psutil.PsutilWrapper), + remove_periodic_timer=None, + subscribers=set(), + ), + ) + await async_process_hardware_platforms(hass) await websocket_api.async_setup(hass) diff --git a/homeassistant/components/hardware/const.py b/homeassistant/components/hardware/const.py index 7fd64d5d968..2bde218c19d 100644 --- a/homeassistant/components/hardware/const.py +++ b/homeassistant/components/hardware/const.py @@ -1,3 +1,14 @@ """Constants for the Hardware integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .models import HardwareData + DOMAIN = "hardware" + +DATA_HARDWARE: HassKey[HardwareData] = HassKey(DOMAIN) diff --git a/homeassistant/components/hardware/hardware.py b/homeassistant/components/hardware/hardware.py index f2de9182b57..9fd257a14a7 100644 --- a/homeassistant/components/hardware/hardware.py +++ b/homeassistant/components/hardware/hardware.py @@ -8,14 +8,14 @@ from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) -from .const import DOMAIN +from .const import DATA_HARDWARE, DOMAIN from .models import HardwareProtocol -async def async_process_hardware_platforms(hass: HomeAssistant) -> None: +async def async_process_hardware_platforms( + hass: HomeAssistant, +) -> None: """Start processing hardware platforms.""" - hass.data[DOMAIN]["hardware_platform"] = {} - await async_process_integration_platforms( hass, DOMAIN, _register_hardware_platform, wait_for_platforms=True ) @@ -30,4 +30,4 @@ def _register_hardware_platform( return if not hasattr(platform, "async_info"): raise HomeAssistantError(f"Invalid hardware platform {platform}") - hass.data[DOMAIN]["hardware_platform"][integration_domain] = platform + hass.data[DATA_HARDWARE].hardware_platform[integration_domain] = platform diff --git a/homeassistant/components/hardware/models.py b/homeassistant/components/hardware/models.py index 6f25d6669cf..a972b567db2 100644 --- a/homeassistant/components/hardware/models.py +++ b/homeassistant/components/hardware/models.py @@ -5,7 +5,27 @@ from __future__ import annotations from dataclasses import dataclass from typing import Protocol -from homeassistant.core import HomeAssistant, callback +import psutil_home_assistant as ha_psutil + +from homeassistant.components import websocket_api +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback + + +@dataclass +class HardwareData: + """Hardware data.""" + + hardware_platform: dict[str, HardwareProtocol] + system_status: SystemStatus + + +@dataclass(slots=True) +class SystemStatus: + """System status.""" + + ha_psutil: ha_psutil + remove_periodic_timer: CALLBACK_TYPE | None + subscribers: set[tuple[websocket_api.ActiveConnection, int]] @dataclass(slots=True) diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index 7224c0f8f7e..599eab34135 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -3,42 +3,25 @@ from __future__ import annotations import contextlib -from dataclasses import asdict, dataclass +from dataclasses import asdict from datetime import datetime, timedelta from typing import Any -import psutil_home_assistant as ha_psutil import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .hardware import async_process_hardware_platforms -from .models import HardwareProtocol - - -@dataclass(slots=True) -class SystemStatus: - """System status.""" - - ha_psutil: ha_psutil - remove_periodic_timer: CALLBACK_TYPE | None - subscribers: set[tuple[websocket_api.ActiveConnection, int]] +from .const import DATA_HARDWARE async def async_setup(hass: HomeAssistant) -> None: """Set up the hardware websocket API.""" websocket_api.async_register_command(hass, ws_info) websocket_api.async_register_command(hass, ws_subscribe_system_status) - hass.data[DOMAIN]["system_status"] = SystemStatus( - ha_psutil=await hass.async_add_executor_job(ha_psutil.PsutilWrapper), - remove_periodic_timer=None, - subscribers=set(), - ) @websocket_api.websocket_command( @@ -53,12 +36,7 @@ async def ws_info( """Return hardware info.""" hardware_info = [] - if "hardware_platform" not in hass.data[DOMAIN]: - await async_process_hardware_platforms(hass) - - hardware_platform: dict[str, HardwareProtocol] = hass.data[DOMAIN][ - "hardware_platform" - ] + hardware_platform = hass.data[DATA_HARDWARE].hardware_platform for platform in hardware_platform.values(): if hasattr(platform, "async_info"): with contextlib.suppress(HomeAssistantError): @@ -78,7 +56,7 @@ def ws_subscribe_system_status( ) -> None: """Subscribe to system status updates.""" - system_status: SystemStatus = hass.data[DOMAIN]["system_status"] + system_status = hass.data[DATA_HARDWARE].system_status @callback def async_update_status(now: datetime) -> None: diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index eeeedff00bb..6772034e53f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -9,8 +9,10 @@ from functools import partial import logging import os import re +import struct from typing import Any, NamedTuple +import aiofiles from aiohasupervisor import SupervisorError import voluptuous as vol @@ -37,6 +39,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, + issue_registry as ir, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.deprecation import ( @@ -51,6 +54,7 @@ from homeassistant.helpers.hassio import ( get_supervisor_ip as _get_supervisor_ip, is_hassio as _is_hassio, ) +from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.service_info.hassio import ( HassioServiceInfo as _HassioServiceInfo, ) @@ -109,7 +113,7 @@ from .coordinator import ( get_core_info, # noqa: F401 get_core_stats, # noqa: F401 get_host_info, # noqa: F401 - get_info, # noqa: F401 + get_info, get_issues_info, # noqa: F401 get_os_info, get_supervisor_info, # noqa: F401 @@ -168,6 +172,11 @@ SERVICE_RESTORE_PARTIAL = "restore_partial" VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) +DEPRECATION_URL = ( + "https://www.home-assistant.io/blog/2025/05/22/" + "deprecating-core-and-supervised-installation-methods-and-32-bit-systems/" +) + def valid_addon(value: Any) -> str: """Validate value is a valid addon slug.""" @@ -225,6 +234,17 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( ) +def _is_32_bit() -> bool: + size = struct.calcsize("P") + return size * 8 == 32 + + +async def _get_arch() -> str: + async with aiofiles.open("/etc/apk/arch") as arch_file: + raw_arch = await arch_file.read() + return {"x86": "i386"}.get(raw_arch, raw_arch) + + class APIEndpointSettings(NamedTuple): """Settings for API endpoint.""" @@ -546,6 +566,62 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = coordinator + arch = await _get_arch() + + def deprecated_setup_issue() -> None: + os_info = get_os_info(hass) + info = get_info(hass) + if os_info is None or info is None: + return + is_haos = info.get("hassos") is not None + board = os_info.get("board") + unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"} + unsupported_os_on_board = board in {"rpi3", "rpi4"} + if is_haos and (unsupported_board or unsupported_os_on_board): + issue_id = "deprecated_os_" + if unsupported_os_on_board: + issue_id += "aarch64" + elif unsupported_board: + issue_id += "armv7" + ir.async_create_issue( + hass, + "homeassistant", + issue_id, + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_guide": "https://www.home-assistant.io/installation/", + }, + ) + bit32 = _is_32_bit() + deprecated_architecture = bit32 and not ( + unsupported_board or unsupported_os_on_board + ) + if not is_haos or deprecated_architecture: + issue_id = "deprecated" + if not is_haos: + issue_id += "_method" + if deprecated_architecture: + issue_id += "_architecture" + ir.async_create_issue( + hass, + "homeassistant", + issue_id, + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_type": "OS" if is_haos else "Supervised", + "arch": arch, + }, + ) + listener() + + listener = coordinator.async_add_listener(deprecated_setup_issue) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 38bf3c82561..7f7bf077e21 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -19,12 +19,14 @@ from aiohasupervisor.exceptions import ( ) from aiohasupervisor.models import ( backups as supervisor_backups, + jobs as supervisor_jobs, mounts as supervisor_mounts, ) from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from homeassistant.components.backup import ( DATA_MANAGER, + AddonErrorData, AddonInfo, AgentBackup, BackupAgent, @@ -295,10 +297,17 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): # It's inefficient to let core do all the copying so we want to let # supervisor handle as much as possible. # Therefore, we split the locations into two lists: encrypted and decrypted. - # The longest list will be sent to supervisor, and the remaining locations - # will be handled by async_upload_backup. - # If the lists are the same length, it does not matter which one we send, - # we send the encrypted list to have a well defined behavior. + # The backup will be created in the first location in the list sent to + # supervisor, and if that location is not available, the backup will + # fail. + # To make it less likely that the backup fails, we prefer to create the + # backup in the local storage location if included in the list of + # locations. + # Hence, we send the list of locations to supervisor in this priority order: + # 1. The list which has local storage + # 2. The longest list of locations + # 3. The list of encrypted locations + # In any case the remaining locations will be handled by async_upload_backup. encrypted_locations: list[str] = [] decrypted_locations: list[str] = [] agents_settings = manager.config.data.agents @@ -313,16 +322,26 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): encrypted_locations.append(hassio_agent.location) else: decrypted_locations.append(hassio_agent.location) + locations = [] + if LOCATION_LOCAL_STORAGE in decrypted_locations: + locations = decrypted_locations + password = None + # Move local storage to the front of the list + decrypted_locations.remove(LOCATION_LOCAL_STORAGE) + decrypted_locations.insert(0, LOCATION_LOCAL_STORAGE) + elif LOCATION_LOCAL_STORAGE in encrypted_locations: + locations = encrypted_locations + # Move local storage to the front of the list + encrypted_locations.remove(LOCATION_LOCAL_STORAGE) + encrypted_locations.insert(0, LOCATION_LOCAL_STORAGE) _LOGGER.debug("Encrypted locations: %s", encrypted_locations) _LOGGER.debug("Decrypted locations: %s", decrypted_locations) - if hassio_agents: + if not locations and hassio_agents: if len(encrypted_locations) >= len(decrypted_locations): locations = encrypted_locations else: locations = decrypted_locations password = None - else: - locations = [] locations = locations or [LOCATION_CLOUD_BACKUP] date = dt_util.now().isoformat() @@ -401,6 +420,34 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): f"Backup failed: {create_errors or 'no backup_id'}" ) + # The backup was created successfully, check for non critical errors + full_status = await self._client.jobs.get_job(backup.job_id) + _addon_errors = _collect_errors( + full_status, "backup_store_addons", "backup_addon_save" + ) + addon_errors: dict[str, AddonErrorData] = {} + for slug, errors in _addon_errors.items(): + try: + addon_info = await self._client.addons.addon_info(slug) + addon_errors[slug] = AddonErrorData( + addon=AddonInfo( + name=addon_info.name, + slug=addon_info.slug, + version=addon_info.version, + ), + errors=errors, + ) + except SupervisorError as err: + _LOGGER.debug("Error getting addon %s: %s", slug, err) + addon_errors[slug] = AddonErrorData( + addon=AddonInfo(name=None, slug=slug, version=None), errors=errors + ) + + _folder_errors = _collect_errors( + full_status, "backup_store_folders", "backup_folder_save" + ) + folder_errors = {Folder(key): val for key, val in _folder_errors.items()} + async def open_backup() -> AsyncIterator[bytes]: try: return await self._client.backups.download_backup(backup_id) @@ -430,7 +477,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) from err return WrittenBackup( + addon_errors=addon_errors, backup=_backup_details_to_agent_backup(details, locations[0]), + folder_errors=folder_errors, open_stream=open_backup, release_stream=remove_backup, ) @@ -474,7 +523,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): details = await self._client.backups.backup_info(backup_id) return WrittenBackup( + addon_errors={}, backup=_backup_details_to_agent_backup(details, locations[0]), + folder_errors={}, open_stream=open_backup, release_stream=remove_backup, ) @@ -696,6 +747,27 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_event(job.to_dict()) +def _collect_errors( + job: supervisor_jobs.Job, child_job_name: str, grandchild_job_name: str +) -> dict[str, list[tuple[str, str]]]: + """Collect errors from a job's grandchildren.""" + errors: dict[str, list[tuple[str, str]]] = {} + for child_job in job.child_jobs: + if child_job.name != child_job_name: + continue + for grandchild in child_job.child_jobs: + if ( + grandchild.name != grandchild_job_name + or not grandchild.errors + or not grandchild.reference + ): + continue + errors[grandchild.reference] = [ + (error.type, error.message) for error in grandchild.errors + ] + return errors + + async def _default_agent(client: SupervisorClient) -> str: """Return the default agent for creating a backup.""" mounts = await client.mounts.info() diff --git a/homeassistant/components/hddtemp/__init__.py b/homeassistant/components/hddtemp/__init__.py index 121238df9fe..66a819f1e8d 100644 --- a/homeassistant/components/hddtemp/__init__.py +++ b/homeassistant/components/hddtemp/__init__.py @@ -1 +1,3 @@ """The hddtemp component.""" + +DOMAIN = "hddtemp" diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 4d9bbeb9516..192ddffd330 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -22,11 +22,14 @@ from homeassistant.const import ( CONF_PORT, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) ATTR_DEVICE = "device" @@ -56,6 +59,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the HDDTemp sensor.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "hddtemp", + }, + ) + name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/homeassistant/components/hdmi_cec/entity.py b/homeassistant/components/hdmi_cec/entity.py index bdb796e6a36..60ea4e1a0d0 100644 --- a/homeassistant/components/hdmi_cec/entity.py +++ b/homeassistant/components/hdmi_cec/entity.py @@ -57,7 +57,7 @@ class CecEntity(Entity): self._attr_available = False self.schedule_update_ha_state(False) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register HDMI callbacks after initialization.""" self._device.set_update_callback(self._update) self.hass.bus.async_listen( diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 810244a815a..dd0cef0ec10 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -50,7 +50,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from . import services -from .const import DOMAIN as HEOS_DOMAIN +from .const import DOMAIN from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 @@ -151,7 +151,7 @@ def catch_action_error[**_P, _R]( return await func(*args, **kwargs) except (HeosError, ValueError) as ex: raise HomeAssistantError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="action_error", translation_placeholders={"action": action, "error": str(ex)}, ) from ex @@ -179,7 +179,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS" model = model_parts[1] if len(model_parts) == 2 else player.model self._attr_device_info = DeviceInfo( - identifiers={(HEOS_DOMAIN, str(player.player_id))}, + identifiers={(DOMAIN, str(player.player_id))}, manufacturer=manufacturer, model=model, name=player.name, @@ -215,7 +215,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): for member_id in player_ids if ( entity_id := entity_registry.async_get_entity_id( - Platform.MEDIA_PLAYER, HEOS_DOMAIN, str(member_id) + Platform.MEDIA_PLAYER, DOMAIN, str(member_id) ) ) ] @@ -379,7 +379,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): return raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="unknown_source", translation_placeholders={"source": source}, ) @@ -406,7 +406,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Set group volume level.""" if self._player.group_id is None: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="entity_not_grouped", translation_placeholders={"entity_id": self.entity_id}, ) @@ -419,7 +419,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Turn group volume down for media player.""" if self._player.group_id is None: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="entity_not_grouped", translation_placeholders={"entity_id": self.entity_id}, ) @@ -430,7 +430,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Turn group volume up for media player.""" if self._player.group_id is None: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="entity_not_grouped", translation_placeholders={"entity_id": self.entity_id}, ) @@ -446,13 +446,13 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): entity_entry = entity_registry.async_get(entity_id) if entity_entry is None: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="entity_not_found", translation_placeholders={"entity_id": entity_id}, ) - if entity_entry.platform != HEOS_DOMAIN: + if entity_entry.platform != DOMAIN: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="not_heos_media_player", translation_placeholders={"entity_id": entity_id}, ) @@ -648,7 +648,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): if media_source.is_media_source_id(media_content_id): return await self._async_browse_media_source(media_content_id) raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="unsupported_media_content_id", translation_placeholders={"media_content_id": media_content_id}, ) diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index c99d73a70d7..76b71f70e28 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -56,8 +56,8 @@ "options": { "step": { "init": { - "title": "HEOS Options", - "description": "You can sign-in to your HEOS Account to access favorites, streaming services, and other features. Clearing the credentials will sign-out of your account.", + "title": "HEOS options", + "description": "You can sign in to your HEOS Account to access favorites, streaming services, and other features. Clearing the credentials will sign out of your account.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -102,7 +102,7 @@ }, "move_queue_item": { "name": "Move queue item", - "description": "Move one or more items within the play queue.", + "description": "Moves one or more items within the play queue.", "fields": { "queue_ids": { "name": "Queue IDs", diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 132b12de4ce..5393dfa5050 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -2,62 +2,32 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from homeassistant.util import dt as dt_util -from .const import ( - CONF_ARRIVAL_TIME, - CONF_DEPARTURE_TIME, - CONF_DESTINATION_ENTITY_ID, - CONF_DESTINATION_LATITUDE, - CONF_DESTINATION_LONGITUDE, - CONF_ORIGIN_ENTITY_ID, - CONF_ORIGIN_LATITUDE, - CONF_ORIGIN_LONGITUDE, - CONF_ROUTE_MODE, - DOMAIN, - TRAVEL_MODE_PUBLIC, -) +from .const import TRAVEL_MODE_PUBLIC from .coordinator import ( + HereConfigEntry, HERERoutingDataUpdateCoordinator, HERETransitDataUpdateCoordinator, ) -from .model import HERETravelTimeConfig PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool: """Set up HERE Travel Time from a config entry.""" api_key = config_entry.data[CONF_API_KEY] - arrival = dt_util.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, "")) - departure = dt_util.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, "")) - - here_travel_time_config = HERETravelTimeConfig( - destination_latitude=config_entry.data.get(CONF_DESTINATION_LATITUDE), - destination_longitude=config_entry.data.get(CONF_DESTINATION_LONGITUDE), - destination_entity_id=config_entry.data.get(CONF_DESTINATION_ENTITY_ID), - origin_latitude=config_entry.data.get(CONF_ORIGIN_LATITUDE), - origin_longitude=config_entry.data.get(CONF_ORIGIN_LONGITUDE), - origin_entity_id=config_entry.data.get(CONF_ORIGIN_ENTITY_ID), - travel_mode=config_entry.data[CONF_MODE], - route_mode=config_entry.options[CONF_ROUTE_MODE], - arrival=arrival, - departure=departure, - ) - cls: type[HERETransitDataUpdateCoordinator | HERERoutingDataUpdateCoordinator] if config_entry.data[CONF_MODE] in {TRAVEL_MODE_PUBLIC, "publicTransportTimeTable"}: cls = HERETransitDataUpdateCoordinator else: cls = HERERoutingDataUpdateCoordinator - data_coordinator = cls(hass, config_entry, api_key, here_travel_time_config) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data_coordinator + data_coordinator = cls(hass, config_entry, api_key) + config_entry.runtime_data = data_coordinator async def _async_update_at_start(_: HomeAssistant) -> None: await data_coordinator.async_refresh() @@ -68,12 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: HereConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index a3345e78e4e..447a45f5d2b 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -26,7 +26,7 @@ from here_transit import ( import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfLength +from homeassistant.const import CONF_MODE, UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.location import find_coordinates @@ -34,25 +34,41 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST -from .model import HERETravelTimeConfig, HERETravelTimeData +from .const import ( + CONF_ARRIVAL_TIME, + CONF_DEPARTURE_TIME, + CONF_DESTINATION_ENTITY_ID, + CONF_DESTINATION_LATITUDE, + CONF_DESTINATION_LONGITUDE, + CONF_ORIGIN_ENTITY_ID, + CONF_ORIGIN_LATITUDE, + CONF_ORIGIN_LONGITUDE, + CONF_ROUTE_MODE, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + ROUTE_MODE_FASTEST, +) +from .model import HERETravelTimeAPIParams, HERETravelTimeData BACKOFF_MULTIPLIER = 1.1 _LOGGER = logging.getLogger(__name__) +type HereConfigEntry = ConfigEntry[ + HERETransitDataUpdateCoordinator | HERERoutingDataUpdateCoordinator +] + class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]): - """here_routing DataUpdateCoordinator.""" + """HERETravelTime DataUpdateCoordinator for the routing API.""" - config_entry: ConfigEntry + config_entry: HereConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HereConfigEntry, api_key: str, - config: HERETravelTimeConfig, ) -> None: """Initialize.""" super().__init__( @@ -63,41 +79,34 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) self._api = HERERoutingApi(api_key) - self.config = config async def _async_update_data(self) -> HERETravelTimeData: """Get the latest data from the HERE Routing API.""" - origin, destination, arrival, departure = prepare_parameters( - self.hass, self.config - ) - - route_mode = ( - RoutingMode.FAST - if self.config.route_mode == ROUTE_MODE_FASTEST - else RoutingMode.SHORT - ) + params = prepare_parameters(self.hass, self.config_entry) _LOGGER.debug( ( "Requesting route for origin: %s, destination: %s, route_mode: %s," " mode: %s, arrival: %s, departure: %s" ), - origin, - destination, - route_mode, - TransportMode(self.config.travel_mode), - arrival, - departure, + params.origin, + params.destination, + params.route_mode, + TransportMode(params.travel_mode), + params.arrival, + params.departure, ) try: response = await self._api.route( - transport_mode=TransportMode(self.config.travel_mode), - origin=here_routing.Place(origin[0], origin[1]), - destination=here_routing.Place(destination[0], destination[1]), - routing_mode=route_mode, - arrival_time=arrival, - departure_time=departure, + transport_mode=TransportMode(params.travel_mode), + origin=here_routing.Place(params.origin[0], params.origin[1]), + destination=here_routing.Place( + params.destination[0], params.destination[1] + ), + routing_mode=params.route_mode, + arrival_time=params.arrival, + departure_time=params.departure, return_values=[Return.POLYINE, Return.SUMMARY], spans=[Spans.NAMES], ) @@ -171,16 +180,15 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] class HERETransitDataUpdateCoordinator( DataUpdateCoordinator[HERETravelTimeData | None] ): - """HERETravelTime DataUpdateCoordinator.""" + """HERETravelTime DataUpdateCoordinator for the transit API.""" - config_entry: ConfigEntry + config_entry: HereConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HereConfigEntry, api_key: str, - config: HERETravelTimeConfig, ) -> None: """Initialize.""" super().__init__( @@ -191,32 +199,31 @@ class HERETransitDataUpdateCoordinator( update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) self._api = HERETransitApi(api_key) - self.config = config async def _async_update_data(self) -> HERETravelTimeData | None: """Get the latest data from the HERE Routing API.""" - origin, destination, arrival, departure = prepare_parameters( - self.hass, self.config - ) + params = prepare_parameters(self.hass, self.config_entry) _LOGGER.debug( ( "Requesting transit route for origin: %s, destination: %s, arrival: %s," " departure: %s" ), - origin, - destination, - arrival, - departure, + params.origin, + params.destination, + params.arrival, + params.departure, ) try: response = await self._api.route( - origin=here_transit.Place(latitude=origin[0], longitude=origin[1]), - destination=here_transit.Place( - latitude=destination[0], longitude=destination[1] + origin=here_transit.Place( + latitude=params.origin[0], longitude=params.origin[1] ), - arrival_time=arrival, - departure_time=departure, + destination=here_transit.Place( + latitude=params.destination[0], longitude=params.destination[1] + ), + arrival_time=params.arrival, + departure_time=params.departure, return_values=[ here_transit.Return.POLYLINE, here_transit.Return.TRAVEL_SUMMARY, @@ -281,8 +288,8 @@ class HERETransitDataUpdateCoordinator( def prepare_parameters( hass: HomeAssistant, - config: HERETravelTimeConfig, -) -> tuple[list[str], list[str], str | None, str | None]: + config_entry: HereConfigEntry, +) -> HERETravelTimeAPIParams: """Prepare parameters for the HERE api.""" def _from_entity_id(entity_id: str) -> list[str]: @@ -301,32 +308,55 @@ def prepare_parameters( return formatted_coordinates # Destination - if config.destination_entity_id is not None: - destination = _from_entity_id(config.destination_entity_id) + if ( + destination_entity_id := config_entry.data.get(CONF_DESTINATION_ENTITY_ID) + ) is not None: + destination = _from_entity_id(str(destination_entity_id)) else: destination = [ - str(config.destination_latitude), - str(config.destination_longitude), + str(config_entry.data[CONF_DESTINATION_LATITUDE]), + str(config_entry.data[CONF_DESTINATION_LONGITUDE]), ] # Origin - if config.origin_entity_id is not None: - origin = _from_entity_id(config.origin_entity_id) + if (origin_entity_id := config_entry.data.get(CONF_ORIGIN_ENTITY_ID)) is not None: + origin = _from_entity_id(str(origin_entity_id)) else: origin = [ - str(config.origin_latitude), - str(config.origin_longitude), + str(config_entry.data[CONF_ORIGIN_LATITUDE]), + str(config_entry.data[CONF_ORIGIN_LONGITUDE]), ] # Arrival/Departure - arrival: str | None = None - departure: str | None = None - if config.arrival is not None: - arrival = next_datetime(config.arrival).isoformat() - if config.departure is not None: - departure = next_datetime(config.departure).isoformat() + arrival: datetime | None = None + if ( + conf_arrival := dt_util.parse_time( + config_entry.options.get(CONF_ARRIVAL_TIME, "") + ) + ) is not None: + arrival = next_datetime(conf_arrival) + departure: datetime | None = None + if ( + conf_departure := dt_util.parse_time( + config_entry.options.get(CONF_DEPARTURE_TIME, "") + ) + ) is not None: + departure = next_datetime(conf_departure) - return (origin, destination, arrival, departure) + route_mode = ( + RoutingMode.FAST + if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST + else RoutingMode.SHORT + ) + + return HERETravelTimeAPIParams( + destination=destination, + origin=origin, + travel_mode=config_entry.data[CONF_MODE], + route_mode=route_mode, + arrival=arrival, + departure=departure, + ) def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None: diff --git a/homeassistant/components/here_travel_time/model.py b/homeassistant/components/here_travel_time/model.py index 178c0d8c805..cbac2b1c353 100644 --- a/homeassistant/components/here_travel_time/model.py +++ b/homeassistant/components/here_travel_time/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import time +from datetime import datetime from typing import TypedDict @@ -21,16 +21,12 @@ class HERETravelTimeData(TypedDict): @dataclass -class HERETravelTimeConfig: - """Configuration for HereTravelTimeDataUpdateCoordinator.""" +class HERETravelTimeAPIParams: + """Configuration for polling the HERE API.""" - destination_latitude: float | None - destination_longitude: float | None - destination_entity_id: str | None - origin_latitude: float | None - origin_longitude: float | None - origin_entity_id: str | None + destination: list[str] + origin: list[str] travel_mode: str route_mode: str - arrival: time | None - departure: time | None + arrival: datetime | None + departure: datetime | None diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 0f0cbb7d3cb..bbaabb56d46 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, @@ -40,6 +39,7 @@ from .const import ( ICONS, ) from .coordinator import ( + HereConfigEntry, HERERoutingDataUpdateCoordinator, HERETransitDataUpdateCoordinator, ) @@ -77,14 +77,14 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HereConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add HERE travel time entities from a config_entry.""" entry_id = config_entry.entry_id name = config_entry.data[CONF_NAME] - coordinator = hass.data[DOMAIN][entry_id] + coordinator = config_entry.runtime_data sensors: list[HERETravelTimeSensor] = [ HERETravelTimeSensor( @@ -164,7 +164,8 @@ class OriginSensor(HERETravelTimeSensor): self, unique_id_prefix: str, name: str, - coordinator: HERERoutingDataUpdateCoordinator, + coordinator: HERERoutingDataUpdateCoordinator + | HERETransitDataUpdateCoordinator, ) -> None: """Initialize the sensor.""" sensor_description = SensorEntityDescription( @@ -192,7 +193,8 @@ class DestinationSensor(HERETravelTimeSensor): self, unique_id_prefix: str, name: str, - coordinator: HERERoutingDataUpdateCoordinator, + coordinator: HERERoutingDataUpdateCoordinator + | HERETransitDataUpdateCoordinator, ) -> None: """Initialize the sensor.""" sensor_description = SensorEntityDescription( diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index 63f32138dba..a3565f9ed77 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -8,8 +8,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, CONF_STATE from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from homeassistant.helpers.template import Template from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS @@ -51,6 +53,30 @@ async def async_setup_entry( entry.options[CONF_ENTITY_ID], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we remove the config entry because + # history_stats does not allow replacing the input entity. + await hass.config_entries.async_remove(entry.entry_id) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_ENTITY_ID] + ), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index 8dbca3b1939..ca3d5229b6b 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.helpers.selector import ( DurationSelector, DurationSelectorConfig, EntitySelector, + EntitySelectorConfig, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -66,6 +67,20 @@ DATA_SCHEMA_SETUP = vol.Schema( ) DATA_SCHEMA_OPTIONS = vol.Schema( { + vol.Optional(CONF_ENTITY_ID): EntitySelector( + EntitySelectorConfig(read_only=True) + ), + vol.Optional(CONF_STATE): TextSelector( + TextSelectorConfig(multiple=True, read_only=True) + ), + vol.Optional(CONF_TYPE): SelectSelector( + SelectSelectorConfig( + options=CONF_TYPE_KEYS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TYPE, + read_only=True, + ) + ), vol.Optional(CONF_START): TemplateSelector(), vol.Optional(CONF_END): TemplateSelector(), vol.Optional(CONF_DURATION): DurationSelector( @@ -92,7 +107,7 @@ OPTIONS_FLOW = { } -class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): +class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for History stats.""" config_flow = CONFIG_FLOW diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json index e10a72f6742..7a33099cf99 100644 --- a/homeassistant/components/history_stats/strings.json +++ b/homeassistant/components/history_stats/strings.json @@ -26,11 +26,17 @@ "options": { "description": "Read the documentation for further details on how to configure the history stats sensor using these options.", "data": { + "entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]", + "state": "[%key:component::history_stats::config::step::user::data::state%]", + "type": "[%key:component::history_stats::config::step::user::data::type%]", "start": "Start", "end": "End", "duration": "Duration" }, "data_description": { + "entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]", + "state": "[%key:component::history_stats::config::step::user::data_description::state%]", + "type": "[%key:component::history_stats::config::step::user::data_description::type%]", "start": "When to start the measure (timestamp or datetime). Can be a template.", "end": "When to stop the measure (timestamp or datetime). Can be a template", "duration": "Duration of the measure." @@ -49,11 +55,17 @@ "init": { "description": "[%key:component::history_stats::config::step::options::description%]", "data": { + "entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]", + "state": "[%key:component::history_stats::config::step::user::data::state%]", + "type": "[%key:component::history_stats::config::step::user::data::type%]", "start": "[%key:component::history_stats::config::step::options::data::start%]", "end": "[%key:component::history_stats::config::step::options::data::end%]", "duration": "[%key:component::history_stats::config::step::options::data::duration%]" }, "data_description": { + "entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]", + "state": "[%key:component::history_stats::config::step::user::data_description::state%]", + "type": "[%key:component::history_stats::config::step::user::data_description::type%]", "start": "[%key:component::history_stats::config::step::options::data_description::start%]", "end": "[%key:component::history_stats::config::step::options::data_description::end%]", "duration": "[%key:component::history_stats::config::step::options::data_description::duration%]" diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index ac008b857af..c45ecd24ea3 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -24,11 +24,11 @@ from .entity import HiveEntity _LOGGER = logging.getLogger(__name__) +type HiveConfigEntry = ConfigEntry[Hive] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool: """Set up Hive from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - web_session = aiohttp_client.async_get_clientsession(hass) hive_config = dict(entry.data) hive = Hive(web_session) @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hive_config["options"].update( {CONF_SCAN_INTERVAL: dict(entry.options).get(CONF_SCAN_INTERVAL, 120)} ) - hass.data[DOMAIN][entry.entry_id] = hive + entry.runtime_data = hive try: devices = await hive.session.startSession(hive_config) @@ -59,16 +59,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> 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) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> None: """Remove a config entry.""" hive = Auth(entry.data["username"], entry.data["password"]) await hive.forget_device( @@ -78,7 +74,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: HiveConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a config entry from a device.""" return True diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index c2fe47642a0..338cc6bcf0a 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -9,11 +9,10 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import HiveConfigEntry from .entity import HiveEntity PARALLEL_UPDATES = 0 @@ -28,12 +27,12 @@ HIVETOHA = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data if devices := hive.session.deviceList.get("alarm_control_panel"): async_add_entities( [HiveAlarmControlPanelEntity(hive, dev) for dev in devices], True diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index 2076d592a7c..cdf6c253916 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -10,11 +10,10 @@ 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 . import HiveConfigEntry from .entity import HiveEntity PARALLEL_UPDATES = 0 @@ -69,12 +68,12 @@ SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data sensors: list[BinarySensorEntity] = [] diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index bd7553faa1a..28062adb0e3 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -15,19 +15,13 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import ( - ATTR_TIME_PERIOD, - DOMAIN, - SERVICE_BOOST_HEATING_OFF, - SERVICE_BOOST_HEATING_ON, -) +from . import HiveConfigEntry, refresh_system +from .const import ATTR_TIME_PERIOD, SERVICE_BOOST_HEATING_OFF, SERVICE_BOOST_HEATING_ON from .entity import HiveEntity HIVE_TO_HASS_STATE = { @@ -59,12 +53,12 @@ _LOGGER = logging.getLogger() async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("climate") if devices: async_add_entities((HiveClimateEntity(hive, dev) for dev in devices), True) diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index e3180dc9734..41dba27c3a5 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -24,6 +23,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback +from . import HiveConfigEntry from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN @@ -37,7 +37,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self.data: dict[str, Any] = {} self.tokens: dict[str, str] = {} - self.entry: ConfigEntry | None = None self.device_registration: bool = False self.device_name = "Home Assistant" @@ -54,7 +53,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): ) # Get user from existing entry and abort if already setup - self.entry = await self.async_set_unique_id(self.data[CONF_USERNAME]) + await self.async_set_unique_id(self.data[CONF_USERNAME]) if self.context["source"] != SOURCE_REAUTH: self._abort_if_unique_id_configured() @@ -145,12 +144,12 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): # Setup the config entry self.data["tokens"] = self.tokens if self.source == SOURCE_REAUTH: - assert self.entry - self.hass.config_entries.async_update_entry( - self.entry, title=self.data["username"], data=self.data + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + title=self.data["username"], + data=self.data, + reason="reauth_successful", ) - await self.hass.config_entries.async_reload(self.entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=self.data["username"], data=self.data) async def async_step_reauth( @@ -166,7 +165,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: HiveConfigEntry, ) -> HiveOptionsFlowHandler: """Hive options callback.""" return HiveOptionsFlowHandler(config_entry) @@ -175,7 +174,9 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): class HiveOptionsFlowHandler(OptionsFlow): """Config flow options for Hive.""" - def __init__(self, config_entry: ConfigEntry) -> None: + config_entry: HiveConfigEntry + + def __init__(self, config_entry: HiveConfigEntry) -> None: """Initialize Hive options flow.""" self.hive = None self.interval = config_entry.options.get(CONF_SCAN_INTERVAL, 120) @@ -190,7 +191,7 @@ class HiveOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - self.hive = self.hass.data["hive"][self.config_entry.entry_id] + self.hive = self.config_entry.runtime_data errors: dict[str, str] = {} if user_input is not None: new_interval = user_input.get(CONF_SCAN_INTERVAL) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 80a81583429..f89d23b8513 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -3,7 +3,9 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING, Any +from typing import Any + +from apyhiveapi import Hive from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -12,30 +14,26 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from . import refresh_system -from .const import ATTR_MODE, DOMAIN +from . import HiveConfigEntry, refresh_system +from .const import ATTR_MODE from .entity import HiveEntity -if TYPE_CHECKING: - from apyhiveapi import Hive - PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive: Hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("light") if not devices: return diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 0609e43c4a9..70a21038d67 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -24,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import HiveConfigEntry from .entity import HiveEntity PARALLEL_UPDATES = 0 @@ -90,11 +89,11 @@ SENSOR_TYPES: tuple[HiveSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("sensor") if not devices: return diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 58ba949d325..2aa17f0e005 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -34,7 +34,7 @@ } }, "error": { - "invalid_username": "Failed to sign in to Hive. Your email address is not recognised.", + "invalid_username": "Failed to sign in to Hive. Your email address is not recognized.", "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.", diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index d4fefea5a56..0640436d105 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -8,13 +8,12 @@ from typing import Any from apyhiveapi import Hive from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import ATTR_MODE, DOMAIN +from . import HiveConfigEntry, refresh_system +from .const import ATTR_MODE from .entity import HiveEntity PARALLEL_UPDATES = 0 @@ -34,12 +33,12 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("switch") if not devices: return diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index 5f0a3d0f3fa..104c4f62f9c 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -10,17 +10,15 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system +from . import HiveConfigEntry, refresh_system from .const import ( ATTR_ONOFF, ATTR_TIME_PERIOD, - DOMAIN, SERVICE_BOOST_HOT_WATER, WATER_HEATER_MODES, ) @@ -46,12 +44,12 @@ SUPPORT_WATER_HEATER = [STATE_ECO, STATE_ON, STATE_OFF] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("water_heater") if devices: async_add_entities((HiveWaterHeater(hive, dev) for dev in devices), True) diff --git a/homeassistant/components/hko/__init__.py b/homeassistant/components/hko/__init__.py index b7e21f731d8..b99fc07bc2f 100644 --- a/homeassistant/components/hko/__init__.py +++ b/homeassistant/components/hko/__init__.py @@ -4,18 +4,17 @@ from __future__ import annotations from hko import LOCATIONS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LOCATION, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_DISTRICT, DOMAIN, KEY_DISTRICT, KEY_LOCATION -from .coordinator import HKOUpdateCoordinator +from .const import DEFAULT_DISTRICT, KEY_DISTRICT, KEY_LOCATION +from .coordinator import HKOConfigEntry, HKOUpdateCoordinator PLATFORMS: list[Platform] = [Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HKOConfigEntry) -> bool: """Set up Hong Kong Observatory from a config entry.""" location = entry.data[CONF_LOCATION] @@ -27,16 +26,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = HKOUpdateCoordinator(hass, entry, websession, district, location) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HKOConfigEntry) -> 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/hko/coordinator.py b/homeassistant/components/hko/coordinator.py index aede960e702..29746c20728 100644 --- a/homeassistant/components/hko/coordinator.py +++ b/homeassistant/components/hko/coordinator.py @@ -65,16 +65,18 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type HKOConfigEntry = ConfigEntry[HKOUpdateCoordinator] + class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """HKO Update Coordinator.""" - config_entry: ConfigEntry + config_entry: HKOConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HKOConfigEntry, session: ClientSession, district: str, location: str, diff --git a/homeassistant/components/hko/weather.py b/homeassistant/components/hko/weather.py index e746d4304d3..075090ecc3f 100644 --- a/homeassistant/components/hko/weather.py +++ b/homeassistant/components/hko/weather.py @@ -5,7 +5,6 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -22,19 +21,18 @@ from .const import ( DOMAIN, MANUFACTURER, ) -from .coordinator import HKOUpdateCoordinator +from .coordinator import HKOConfigEntry, HKOUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HKOConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a HKO weather entity from a config_entry.""" assert config_entry.unique_id is not None unique_id = config_entry.unique_id - coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([HKOEntity(unique_id, coordinator)], False) + async_add_entities([HKOEntity(unique_id, config_entry.runtime_data)], False) class HKOEntity(CoordinatorEntity[HKOUpdateCoordinator], WeatherEntity): diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index ebd92908b93..f55535d9be0 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -3,6 +3,7 @@ import logging from hlk_sw16 import create_hlk_sw16_connection +from hlk_sw16.protocol import SW16Client import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -24,9 +25,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SWITCH] -DATA_DEVICE_REGISTER = "hlk_sw16_device_register" -DATA_DEVICE_LISTENER = "hlk_sw16_device_listener" - SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) RELAY_ID = vol.All( @@ -52,6 +50,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type HlkConfigEntry = ConfigEntry[SW16Client] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Component setup, do nothing.""" @@ -70,15 +70,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HlkConfigEntry) -> bool: """Set up the HLK-SW16 switch.""" - hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] address = f"{host}:{port}" - hass.data[DOMAIN][entry.entry_id] = {} - @callback def disconnected(): """Schedule reconnect after connection has been lost.""" @@ -106,7 +103,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, ) - hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] = client + entry.runtime_data = client # Load entities await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -116,14 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HlkConfigEntry) -> bool: """Unload a config entry.""" - client = hass.data[DOMAIN][entry.entry_id].pop(DATA_DEVICE_REGISTER) - client.stop() - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - if hass.data[DOMAIN][entry.entry_id]: - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - return unload_ok + entry.runtime_data.stop() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hlk_sw16/entity.py b/homeassistant/components/hlk_sw16/entity.py index 91510760968..d3784fef5ee 100644 --- a/homeassistant/components/hlk_sw16/entity.py +++ b/homeassistant/components/hlk_sw16/entity.py @@ -2,6 +2,8 @@ import logging +from hlk_sw16.protocol import SW16Client + from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -17,12 +19,12 @@ class SW16Entity(Entity): _attr_should_poll = False - def __init__(self, device_port, entry_id, client): + def __init__(self, device_port: str, entry_id: str, client: SW16Client) -> None: """Initialize the device.""" # HLK-SW16 specific attributes for every component type self._entry_id = entry_id self._device_port = device_port - self._is_on = None + self._is_on: bool | None = None self._client = client self._attr_name = device_port self._attr_unique_id = f"{self._entry_id}_{self._device_port}" diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py index c6e6f7f5201..795f3dc68ea 100644 --- a/homeassistant/components/hlk_sw16/switch.py +++ b/homeassistant/components/hlk_sw16/switch.py @@ -1,22 +1,22 @@ """Support for HLK-SW16 switches.""" +from __future__ import annotations + from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DATA_DEVICE_REGISTER -from .const import DOMAIN +from . import HlkConfigEntry from .entity import SW16Entity PARALLEL_UPDATES = 0 -def devices_from_entities(hass, entry): +def devices_from_entities(entry: HlkConfigEntry) -> list[SW16Switch]: """Parse configuration and add HLK-SW16 switch devices.""" - device_client = hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] + device_client = entry.runtime_data devices = [] for i in range(16): device_port = f"{i:01x}" @@ -27,18 +27,18 @@ def devices_from_entities(hass, entry): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HlkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HLK-SW16 platform.""" - async_add_entities(devices_from_entities(hass, entry)) + async_add_entities(devices_from_entities(entry)) class SW16Switch(SW16Entity, SwitchEntity): """Representation of a HLK-SW16 switch.""" @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._is_on diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py index 1c01319129b..c5b67b7d555 100644 --- a/homeassistant/components/holiday/calendar.py +++ b/homeassistant/components/holiday/calendar.py @@ -25,17 +25,12 @@ def _get_obj_holidays_and_language( selected_categories: list[str] | None, ) -> tuple[HolidayBase, str]: """Get the object for the requested country and year.""" - if selected_categories is None: - categories = [PUBLIC] - else: - categories = [PUBLIC, *selected_categories] - obj_holidays = country_holidays( country, subdiv=province, years={dt_util.now().year, dt_util.now().year + 1}, language=language, - categories=categories, + categories=selected_categories, ) if language == "en": for lang in obj_holidays.supported_languages: @@ -45,7 +40,7 @@ def _get_obj_holidays_and_language( subdiv=province, years={dt_util.now().year, dt_util.now().year + 1}, language=lang, - categories=categories, + categories=selected_categories, ) language = lang break @@ -59,7 +54,7 @@ def _get_obj_holidays_and_language( subdiv=province, years={dt_util.now().year, dt_util.now().year + 1}, language=default_language, - categories=categories, + categories=selected_categories, ) language = default_language @@ -77,6 +72,11 @@ async def async_setup_entry( categories: list[str] | None = config_entry.options.get(CONF_CATEGORIES) language = hass.config.language + if categories is None: + categories = [PUBLIC] + else: + categories = [PUBLIC, *categories] + obj_holidays, language = await hass.async_add_executor_job( _get_obj_holidays_and_language, country, province, language, categories ) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index bd6fd51e726..5a5f1daf967 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.73", "babel==2.15.0"] + "requirements": ["holidays==0.74", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py index d66255e6810..20a3a211b6a 100644 --- a/homeassistant/components/home_connect/application_credentials.py +++ b/homeassistant/components/home_connect/application_credentials.py @@ -12,3 +12,13 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe 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 { + "developer_dashboard_url": "https://developer.home-connect.com/", + "applications_url": "https://developer.home-connect.com/applications", + "register_application_url": "https://developer.home-connect.com/application/add", + "redirect_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py index 2b3b2aacf0c..9c7da4d98df 100644 --- a/homeassistant/components/home_connect/config_flow.py +++ b/homeassistant/components/home_connect/config_flow.py @@ -8,7 +8,8 @@ import jwt import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -58,3 +59,22 @@ class OAuth2FlowHandler( ) self._abort_if_unique_id_configured() return await super().async_oauth_create_entry(data) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a DHCP discovery.""" + device_registry = dr.async_get(self.hass) + if device_entry := device_registry.async_get_device( + identifiers={ + (DOMAIN, discovery_info.hostname), + (DOMAIN, discovery_info.hostname.split("-")[-1]), + } + ): + device_registry.async_update_device( + device_entry.id, + new_connections={ + (dr.CONNECTION_NETWORK_MAC, discovery_info.macaddress) + }, + ) + return await super().async_step_dhcp(discovery_info) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 9e40de86e24..3c9d33424a8 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -5,7 +5,6 @@ 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 @@ -137,11 +136,8 @@ class HomeConnectCoordinator( self.__dict__.pop("context_listeners", None) def remove_listener_and_invalidate_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) + remove_listener() + self.__dict__.pop("context_listeners", None) return remove_listener_and_invalidate_context_listeners diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index fd74277a815..59856999ec7 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import Any -from aiohomeconnect.client import Client as HomeConnectClient - from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -14,7 +12,7 @@ from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry async def _generate_appliance_diagnostics( - client: HomeConnectClient, appliance: HomeConnectApplianceData + appliance: HomeConnectApplianceData, ) -> dict[str, Any]: return { **appliance.info.to_dict(), @@ -31,9 +29,7 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return { - appliance.info.ha_id: await _generate_appliance_diagnostics( - entry.runtime_data.client, appliance - ) + appliance.info.ha_id: await _generate_appliance_diagnostics(appliance) for appliance in entry.runtime_data.data.values() } @@ -45,6 +41,4 @@ async def async_get_device_diagnostics( ha_id = next( (identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN), ) - return await _generate_appliance_diagnostics( - entry.runtime_data.client, entry.runtime_data.data[ha_id] - ) + return await _generate_appliance_diagnostics(entry.runtime_data.data[ha_id]) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index facb3b14a9b..a3368ce550c 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -32,7 +32,6 @@ _LOGGER = logging.getLogger(__name__) class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): """Generic Home Connect entity (base class).""" - _attr_should_poll = False _attr_has_entity_name = True def __init__( diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index de55a60bd43..b4ea57c63f6 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -39,11 +39,11 @@ PARALLEL_UPDATES = 1 class HomeConnectLightEntityDescription(LightEntityDescription): """Light entity description.""" - brightness_key: SettingKey | None = None + brightness_key: SettingKey + brightness_scale: tuple[float, float] color_key: SettingKey | None = None enable_custom_color_value_key: str | None = None custom_color_key: SettingKey | None = None - brightness_scale: tuple[float, float] = (0.0, 100.0) LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 8a608a900be..d4b37552fb7 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -4,9 +4,23 @@ "codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"], "config_flow": true, "dependencies": ["application_credentials", "repairs"], + "dhcp": [ + { + "hostname": "balay-*", + "macaddress": "C8D778*" + }, + { + "hostname": "(balay|bosch|neff|siemens)-*", + "macaddress": "68A40E*" + }, + { + "hostname": "(siemens|neff)-*", + "macaddress": "38B4D3*" + } + ], "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.17.0"], + "requirements": ["aiohomeconnect==0.17.1"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 1bb793f4015..790036d26f8 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -11,6 +11,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -79,7 +80,7 @@ NUMBERS = ( NumberEntityDescription( key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE_PERCENT, translation_key="color_temperature_percent", - native_unit_of_measurement="%", + native_unit_of_measurement=PERCENTAGE, ), NumberEntityDescription( key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL, diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 0f0161971a2..d8fda46385d 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,10 +1,7 @@ """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 @@ -17,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -45,6 +42,7 @@ class HomeConnectSensorEntityDescription( ): """Entity Description class for sensors.""" + default_value: str | None = None appliance_types: tuple[str, ...] | None = None fetch_unit: bool = False @@ -159,7 +157,6 @@ SENSORS = ( HomeConnectSensorEntityDescription( key=StatusKey.BSH_COMMON_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, - translation_key="battery_level", ), HomeConnectSensorEntityDescription( key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE, @@ -200,6 +197,7 @@ 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"), ), @@ -207,6 +205,7 @@ 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,6 +221,7 @@ 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"), ), @@ -229,6 +229,7 @@ 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"), ), @@ -236,6 +237,7 @@ 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",), ), @@ -243,6 +245,7 @@ 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",), ), @@ -250,6 +253,7 @@ 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",), ), @@ -257,6 +261,7 @@ 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",), ), @@ -264,6 +269,7 @@ 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",), ), @@ -271,6 +277,7 @@ 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",), ), @@ -278,6 +285,7 @@ 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",), ), @@ -285,6 +293,7 @@ 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",), ), @@ -292,6 +301,7 @@ 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",), ), @@ -299,6 +309,7 @@ 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",), ), @@ -306,6 +317,7 @@ 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",), ), @@ -313,6 +325,7 @@ 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",), ), @@ -320,6 +333,7 @@ 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",), ), @@ -327,6 +341,7 @@ 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",), ), @@ -334,6 +349,7 @@ 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",), ), @@ -341,6 +357,7 @@ 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",), ), @@ -348,6 +365,7 @@ 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",), ), @@ -355,6 +373,7 @@ 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",), ), @@ -362,6 +381,7 @@ 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",), ), @@ -369,6 +389,7 @@ 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",), ), @@ -376,6 +397,7 @@ 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",), ), @@ -383,6 +405,7 @@ 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",), ), @@ -390,6 +413,7 @@ 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",), ), @@ -397,6 +421,7 @@ 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",), ), @@ -404,6 +429,7 @@ 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"), ), @@ -411,6 +437,7 @@ 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"), ), @@ -418,6 +445,7 @@ 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"), ), @@ -425,6 +453,7 @@ 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",), ), @@ -432,6 +461,7 @@ 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",), ), @@ -439,6 +469,7 @@ 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",), ), @@ -446,6 +477,7 @@ 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"), ), @@ -453,6 +485,7 @@ 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"), ), @@ -460,6 +493,7 @@ 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",), ), @@ -467,6 +501,7 @@ 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",), ), @@ -479,6 +514,12 @@ 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 @@ -492,72 +533,6 @@ 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, @@ -570,32 +545,6 @@ 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.""" @@ -698,7 +647,12 @@ class HomeConnectProgramSensor(HomeConnectSensor): class HomeConnectEventSensor(HomeConnectSensor): """Sensor class for Home Connect events.""" + _attr_entity_registry_enabled_default = False + def update_native_value(self) -> None: """Update the sensor's status.""" - event = self.appliance.events[cast(EventKey, self.bsh_key)] - self._update_native_value(event.value) + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + elif self._attr_native_value is None: + self._attr_native_value = self.entity_description.default_value diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7e364a6aa50..1445a8eae08 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Login to Home Connect requires a client ID and secret. To acquire them, please follow the following steps.\n\n1. Visit the [Home Connect Developer Program website]({developer_dashboard_url}) and sign up for a development account.\n1. Enter the email of your login for the original Home Connect app under **Default Home Connect User Account for Testing** in the signup process.\n1. Go to the [Applications]({applications_url}) page and select [Register Application]({register_application_url}) and set the fields to the following values:\n * **Application ID**: Home Assistant (or any other name that makes sense)\n * **OAuth Flow**: Authorization Code Grant Flow\n * **Redirect URI**: `{redirect_url}`\n\nIn the newly created application's details, you will find the **Client ID** and the **Client Secret**." + }, "common": { "confirmed": "Confirmed", "present": "Present" @@ -11,6 +14,9 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Home Connect integration needs to re-authenticate your account" + }, + "oauth_discovery": { + "description": "Home Assistant has found a Home Connect device on your network. Be aware that the setup of Home Connect is more complicated than many other integrations. Press **Submit** to continue setting up Home Connect." } }, "abort": { @@ -156,28 +162,6 @@ } } }, - "deprecated_program_switch_in_automations_scripts": { - "title": "Deprecated program switch detected in some automations or scripts", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::deprecated_program_switch_in_automations_scripts::title%]", - "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." - } - } - } - }, - "deprecated_program_switch": { - "title": "Deprecated program switch entities", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::deprecated_program_switch::title%]", - "description": "The switch entity `{entity_id}` and all the other program switches are deprecated.\n\nPlease use the active program select entity instead." - } - } - } - }, "deprecated_set_program_and_option_actions": { "title": "The executed action is deprecated", "fix_flow": { @@ -1585,9 +1569,6 @@ "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" - }, "camera_state": { "name": "Camera state", "state": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 05f0ed2ddc3..cb032a5815d 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -3,31 +3,18 @@ import logging from typing import Any, cast -from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey +from aiohomeconnect.model import OptionKey, SettingKey from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.program import EnumerateProgram -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -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 homeassistant.helpers.typing import UNDEFINED, UndefinedType from .common import setup_home_connect_entry from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error @@ -154,11 +141,6 @@ def _get_entities_for_appliance( ) -> list[HomeConnectEntity]: """Get a list of entities.""" entities: list[HomeConnectEntity] = [] - entities.extend( - HomeConnectProgramSwitch(entry.runtime_data, appliance, program) - for program in appliance.programs - if program.key != ProgramKey.UNKNOWN - ) if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: entities.append( HomeConnectPowerSwitch( @@ -247,142 +229,6 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value -class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): - """Switch class for Home Connect.""" - - def __init__( - self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, - program: EnumerateProgram, - ) -> None: - """Initialize the entity.""" - desc = " ".join(["Program", program.key.split(".")[-1]]) - if appliance.info.type == "WasherDryer": - desc = " ".join( - ["Program", program.key.split(".")[-3], program.key.split(".")[-1]] - ) - self.program = program - super().__init__( - coordinator, - appliance, - SwitchEntityDescription( - key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - entity_registry_enabled_default=False, - ), - ) - self._attr_name = f"{appliance.info.name} {desc}" - self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" - self._attr_has_entity_name = False - - 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_program_switch_in_automations_scripts_{self.entity_id}", - breaks_in_ha_version="2025.6.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_program_switch_in_automations_scripts", - translation_placeholders={ - "entity_id": 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.""" - async_delete_issue( - self.hass, - DOMAIN, - f"deprecated_program_switch_in_automations_scripts_{self.entity_id}", - ) - async_delete_issue( - self.hass, DOMAIN, f"deprecated_program_switch_{self.entity_id}" - ) - - def create_action_handler_issue(self) -> None: - """Create deprecation issue.""" - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_program_switch_{self.entity_id}", - breaks_in_ha_version="2025.6.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_program_switch", - translation_placeholders={ - "entity_id": self.entity_id, - }, - ) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Start the program.""" - self.create_action_handler_issue() - try: - await self.coordinator.client.start_program( - self.appliance.info.ha_id, program_key=self.program.key - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="start_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "program": self.program.key, - }, - ) from err - - async def async_turn_off(self, **kwargs: Any) -> None: - """Stop the program.""" - self.create_action_handler_issue() - try: - await self.coordinator.client.stop_program(self.appliance.info.ha_id) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="stop_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - }, - ) from err - - def update_native_value(self) -> None: - """Update the switch's status based on if the program related to this entity is currently active.""" - event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM) - self._attr_is_on = bool(event and event.value == self.program.key) - - class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Power switch class for Home Connect.""" diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index adf26d2d973..6a6e57c4dd3 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -79,7 +79,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" await super().async_added_to_hass() - if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK: + if self.bsh_key is SettingKey.BSH_COMMON_ALARM_CLOCK: automations = automations_with_entity(self.hass, self.entity_id) scripts = scripts_with_entity(self.hass, self.entity_id) items = automations + scripts @@ -123,7 +123,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" - if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK: + if self.bsh_key is SettingKey.BSH_COMMON_ALARM_CLOCK: async_delete_issue( self.hass, DOMAIN, diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index dc33b0c63e3..4360fa9c16e 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -4,8 +4,10 @@ import asyncio from collections.abc import Callable, Coroutine import itertools as it import logging +import struct from typing import Any +import aiofiles import voluptuous as vol from homeassistant import config as conf_util, core_config @@ -31,14 +33,21 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser -from homeassistant.helpers import config_validation as cv, recorder, restore_state +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + recorder, + restore_state, +) from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.service import ( async_extract_config_entry_ids, async_extract_referenced_entity_ids, async_register_admin_service, ) from homeassistant.helpers.signal import KEY_HA_STOP +from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType @@ -81,6 +90,22 @@ SCHEMA_RESTART = vol.Schema({vol.Optional(ATTR_SAFE_MODE, default=False): bool}) SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) +DEPRECATION_URL = ( + "https://www.home-assistant.io/blog/2025/05/22/" + "deprecating-core-and-supervised-installation-methods-and-32-bit-systems/" +) + + +def _is_32_bit() -> bool: + size = struct.calcsize("P") + return size * 8 == 32 + + +async def _get_arch() -> str: + async with aiofiles.open("/etc/apk/arch") as arch_file: + raw_arch = (await arch_file.read()).strip() + return {"x86": "i386", "x86_64": "amd64"}.get(raw_arch, raw_arch) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" @@ -386,6 +411,46 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities async_set_stop_handler(hass, _async_stop) + info = await async_get_system_info(hass) + + installation_type = info["installation_type"][15:] + if installation_type in {"Core", "Container"}: + deprecated_method = installation_type == "Core" + bit32 = _is_32_bit() + arch = info["arch"] + if bit32 and installation_type == "Container": + arch = await _get_arch() + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_container", + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_container", + translation_placeholders={"arch": arch}, + ) + deprecated_architecture = bit32 and installation_type != "Container" + if deprecated_method or deprecated_architecture: + issue_id = "deprecated" + if deprecated_method: + issue_id += "_method" + if deprecated_architecture: + issue_id += "_architecture" + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_type": installation_type, + "arch": arch, + }, + ) + return True diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index b8b5f77cf52..940af999c4d 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -18,6 +18,14 @@ "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 the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." }, + "deprecated_system_packages_config_flow_integration": { + "title": "The {integration_title} integration is being removed", + "description": "The {integration_title} integration is being removed as it depends on system packages that can only be installed on systems running a deprecated architecture. To resolve this, remove all \"{integration_title}\" config entries." + }, + "deprecated_system_packages_yaml_integration": { + "title": "The {integration_title} integration is being removed", + "description": "The {integration_title} integration is being removed as it depends on system packages that can only be installed on systems running a deprecated architecture. To resolve this, remove the {domain} entry from your configuration.yaml file and restart Home Assistant." + }, "historic_currency": { "title": "The configured currency is no longer in use", "description": "The currency {currency} is no longer in use, please reconfigure the currency configuration." @@ -86,6 +94,30 @@ } } } + }, + "deprecated_method": { + "title": "Deprecation notice: Installation method", + "description": "This system is using the {installation_type} installation type, which has been deprecated and will become unsupported following the release of Home Assistant 2025.12. While you can continue using your current setup after that point, we strongly recommend migrating to a supported installation method." + }, + "deprecated_method_architecture": { + "title": "Deprecation notice", + "description": "This system is using the {installation_type} installation type, and 32-bit hardware (`{arch}`), both of which have been deprecated and will no longer be supported after the release of Home Assistant 2025.12." + }, + "deprecated_architecture": { + "title": "Deprecation notice: 32-bit architecture", + "description": "This system uses 32-bit hardware (`{arch}`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. As your hardware is no longer capable of running newer versions of Home Assistant, you will need to migrate to new hardware." + }, + "deprecated_container": { + "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]", + "description": "This system is running on a 32-bit operating system (`{arch}`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. Check if your system is capable of running a 64-bit operating system. If not, you will need to migrate to new hardware." + }, + "deprecated_os_aarch64": { + "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]", + "description": "This system is running on a 32-bit operating system (`armv7`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. To continue using Home Assistant on this hardware, you will need to install a 64-bit operating system. Please refer to our [installation guide]({installation_guide})." + }, + "deprecated_os_armv7": { + "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]", + "description": "This system is running on a 32-bit operating system (`armv7`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. As your hardware is no longer capable of running newer versions of Home Assistant, you will need to migrate to new hardware." } }, "system_health": { diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 985e4819b24..8065c23c5c1 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -75,14 +75,18 @@ async def async_attach_trigger( event_data.update( template.render_complex(config[CONF_EVENT_DATA], variables, limited=True) ) - # Build the schema or a an items view if the schema is simple - # and does not contain sub-dicts. We explicitly do not check for - # list like the context data below since lists are a special case - # only for context data. (see test test_event_data_with_list) + + # For performance reasons, we want to avoid using a voluptuous schema here + # unless required. Thus, if possible, we try to use a simple items comparison + # For that, we explicitly do not check for list like the context data below + # since lists are a special case only used for context data, see test + # test_event_data_with_list. Otherwise, we build a volutupus schema, see test + # test_event_data_with_list_nested if any(isinstance(value, dict) for value in event_data.values()): event_data_schema = vol.Schema( - {vol.Required(key): value for key, value in event_data.items()}, + event_data, extra=vol.ALLOW_EXTRA, + required=True, ) else: # Use a simple items comparison if possible diff --git a/homeassistant/components/homeassistant_green/hardware.py b/homeassistant/components/homeassistant_green/hardware.py index 0537d17620b..bf0decb9d05 100644 --- a/homeassistant/components/homeassistant_green/hardware.py +++ b/homeassistant/components/homeassistant_green/hardware.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAME = "Home Assistant Green" -DOCUMENTATION_URL = "https://green.home-assistant.io/documentation/" +DOCUMENTATION_URL = "https://support.nabucasa.com/hc/en-us/categories/24638797677853-Home-Assistant-Green" MANUFACTURER = "homeassistant" MODEL = "green" diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 6dda01561f1..e184f9b3a85 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -28,7 +28,7 @@ }, "confirm_zigbee": { "title": "Zigbee setup complete", - "description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration once you exit." + "description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration." }, "install_otbr_addon": { "title": "Installing OpenThread Border Router add-on", @@ -44,7 +44,7 @@ }, "confirm_otbr": { "title": "OpenThread Border Router setup complete", - "description": "Your {model} is now an OpenThread Border Router and will show up in the Thread integration once you exit." + "description": "Your {model} is now an OpenThread Border Router and will show up in the Thread integration." } }, "abort": { diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py index 9bfa5d16655..bf4ffefdc75 100644 --- a/homeassistant/components/homeassistant_sky_connect/hardware.py +++ b/homeassistant/components/homeassistant_sky_connect/hardware.py @@ -9,7 +9,7 @@ from .config_flow import HomeAssistantSkyConnectConfigFlow from .const import DOMAIN from .util import get_hardware_variant -DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/" +DOCUMENTATION_URL = "https://support.nabucasa.com/hc/en-us/categories/24734620813469-Home-Assistant-Connect-ZBT-1" EXPECTED_ENTRY_VERSION = ( HomeAssistantSkyConnectConfigFlow.VERSION, HomeAssistantSkyConnectConfigFlow.MINOR_VERSION, diff --git a/homeassistant/components/homeassistant_yellow/hardware.py b/homeassistant/components/homeassistant_yellow/hardware.py index 2b9ee0673db..2064f33484c 100644 --- a/homeassistant/components/homeassistant_yellow/hardware.py +++ b/homeassistant/components/homeassistant_yellow/hardware.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAME = "Home Assistant Yellow" -DOCUMENTATION_URL = "https://yellow.home-assistant.io/documentation/" +DOCUMENTATION_URL = "https://support.nabucasa.com/hc/en-us/categories/24734575925149-Home-Assistant-Yellow" MANUFACTURER = "homeassistant" MODEL = "yellow" diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index fbd34743496..83705d4fed1 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -15,10 +15,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, + Platform.FAN, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, diff --git a/homeassistant/components/homee/alarm_control_panel.py b/homeassistant/components/homee/alarm_control_panel.py new file mode 100644 index 00000000000..fd7371b31e4 --- /dev/null +++ b/homeassistant/components/homee/alarm_control_panel.py @@ -0,0 +1,138 @@ +"""The Homee alarm control panel platform.""" + +from dataclasses import dataclass + +from pyHomee.const import AttributeChangedBy, AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityDescription, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import DOMAIN, HomeeConfigEntry +from .entity import HomeeEntity +from .helpers import get_name_for_enum + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class HomeeAlarmControlPanelEntityDescription(AlarmControlPanelEntityDescription): + """A class that describes Homee alarm control panel entities.""" + + code_arm_required: bool = False + state_list: list[AlarmControlPanelState] + + +ALARM_DESCRIPTIONS = { + AttributeType.HOMEE_MODE: HomeeAlarmControlPanelEntityDescription( + key="homee_mode", + code_arm_required=False, + state_list=[ + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelState.ARMED_NIGHT, + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.ARMED_VACATION, + ], + ) +} + + +def get_supported_features( + state_list: list[AlarmControlPanelState], +) -> AlarmControlPanelEntityFeature: + """Return supported features based on the state list.""" + supported_features = AlarmControlPanelEntityFeature(0) + if AlarmControlPanelState.ARMED_HOME in state_list: + supported_features |= AlarmControlPanelEntityFeature.ARM_HOME + if AlarmControlPanelState.ARMED_AWAY in state_list: + supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY + if AlarmControlPanelState.ARMED_NIGHT in state_list: + supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT + if AlarmControlPanelState.ARMED_VACATION in state_list: + supported_features |= AlarmControlPanelEntityFeature.ARM_VACATION + return supported_features + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the alarm control panel component.""" + + async_add_entities( + HomeeAlarmPanel(attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in ALARM_DESCRIPTIONS and attribute.editable + ) + + +class HomeeAlarmPanel(HomeeEntity, AlarmControlPanelEntity): + """Representation of a Homee alarm control panel.""" + + entity_description: HomeeAlarmControlPanelEntityDescription + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: HomeeAlarmControlPanelEntityDescription, + ) -> None: + """Initialize a Homee alarm control panel entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_code_arm_required = description.code_arm_required + self._attr_supported_features = get_supported_features(description.state_list) + self._attr_translation_key = description.key + + @property + def alarm_state(self) -> AlarmControlPanelState: + """Return current state.""" + return self.entity_description.state_list[int(self._attribute.current_value)] + + @property + def changed_by(self) -> str: + """Return by whom or what the entity was last changed.""" + changed_by_name = get_name_for_enum( + AttributeChangedBy, self._attribute.changed_by + ) + return f"{changed_by_name} - {self._attribute.changed_by_id}" + + async def _async_set_alarm_state(self, state: AlarmControlPanelState) -> None: + """Set the alarm state.""" + if state in self.entity_description.state_list: + await self.async_set_homee_value( + self.entity_description.state_list.index(state) + ) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + # Since disarm is always present in the UI, we raise an error. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="disarm_not_supported", + ) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self._async_set_alarm_state(AlarmControlPanelState.ARMED_HOME) + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm night command.""" + await self._async_set_alarm_state(AlarmControlPanelState.ARMED_NIGHT) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._async_set_alarm_state(AlarmControlPanelState.ARMED_AWAY) + + async def async_alarm_arm_vacation(self, code: str | None = None) -> None: + """Send arm vacation command.""" + await self._async_set_alarm_state(AlarmControlPanelState.ARMED_VACATION) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 468fb2d49ac..7bc3de189d6 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -96,5 +96,7 @@ LIGHT_PROFILES = [ NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH, ] -# Climate Presets +# Preset modes +PRESET_AUTO = "auto" PRESET_MANUAL = "manual" +PRESET_SUMMER = "summer" diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 165a655d82b..4c85f52bb28 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -27,14 +27,20 @@ class HomeeEntity(Entity): ) self._entry = entry node = entry.runtime_data.get_node_by_id(attribute.node_id) - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}") - }, - name=node.name, - model=get_name_for_enum(NodeProfile, node.profile), - via_device=(DOMAIN, entry.runtime_data.settings.uid), - ) + # Homee hub itself has node-id -1 + if node.id == -1: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.runtime_data.settings.uid)}, + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}") + }, + name=node.name, + model=get_name_for_enum(NodeProfile, node.profile), + via_device=(DOMAIN, entry.runtime_data.settings.uid), + ) self._host_connected = entry.runtime_data.connected diff --git a/homeassistant/components/homee/event.py b/homeassistant/components/homee/event.py new file mode 100644 index 00000000000..047d9e2e122 --- /dev/null +++ b/homeassistant/components/homee/event.py @@ -0,0 +1,61 @@ +"""The homee event platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add event entities for homee.""" + + async_add_entities( + HomeeEvent(attribute, config_entry) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type == AttributeType.UP_DOWN_REMOTE + ) + + +class HomeeEvent(HomeeEntity, EventEntity): + """Representation of a homee event.""" + + _attr_translation_key = "up_down_remote" + _attr_event_types = [ + "released", + "up", + "down", + "stop", + "up_long", + "down_long", + "stop_long", + "c_button", + "b_button", + "a_button", + ] + _attr_device_class = EventDeviceClass.BUTTON + + async def async_added_to_hass(self) -> None: + """Add the homee event entity to home assistant.""" + await super().async_added_to_hass() + self.async_on_remove( + self._attribute.add_on_changed_listener(self._event_triggered) + ) + + @callback + def _event_triggered(self, event: HomeeAttribute) -> None: + """Handle a homee event.""" + if event.type == AttributeType.UP_DOWN_REMOTE: + self._trigger_event(self.event_types[int(event.current_value)]) + self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/fan.py b/homeassistant/components/homee/fan.py new file mode 100644 index 00000000000..d4694ee8d66 --- /dev/null +++ b/homeassistant/components/homee/fan.py @@ -0,0 +1,134 @@ +"""The Homee fan platform.""" + +import math +from typing import Any, cast + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +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 . import HomeeConfigEntry +from .const import DOMAIN, PRESET_AUTO, PRESET_MANUAL, PRESET_SUMMER +from .entity import HomeeNodeEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Homee fan platform.""" + + async_add_devices( + HomeeFan(node, config_entry) + for node in config_entry.runtime_data.nodes + if node.profile == NodeProfile.VENTILATION_CONTROL + ) + + +class HomeeFan(HomeeNodeEntity, FanEntity): + """Representation of a Homee fan entity.""" + + _attr_translation_key = DOMAIN + _attr_name = None + _attr_preset_modes = [PRESET_MANUAL, PRESET_AUTO, PRESET_SUMMER] + speed_range = (1, 8) + _attr_speed_count = int_states_in_range(speed_range) + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize a Homee fan entity.""" + super().__init__(node, entry) + self._speed_attribute: HomeeAttribute = cast( + HomeeAttribute, node.get_attribute_by_type(AttributeType.VENTILATION_LEVEL) + ) + self._mode_attribute: HomeeAttribute = cast( + HomeeAttribute, node.get_attribute_by_type(AttributeType.VENTILATION_MODE) + ) + + @property + def supported_features(self) -> FanEntityFeature: + """Return the supported features based on preset_mode.""" + features = FanEntityFeature.PRESET_MODE + + if self.preset_mode == PRESET_MANUAL: + features |= ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) + + return features + + @property + def is_on(self) -> bool: + """Return true if the entity is on.""" + return self.percentage > 0 + + @property + def percentage(self) -> int: + """Return the current speed percentage.""" + return ranged_value_to_percentage( + self.speed_range, self._speed_attribute.current_value + ) + + @property + def preset_mode(self) -> str: + """Return the mode from the float state.""" + return self._attr_preset_modes[int(self._mode_attribute.current_value)] + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + await self.async_set_homee_value( + self._speed_attribute, + math.ceil(percentage_to_ranged_value(self.speed_range, percentage)), + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self.async_set_homee_value( + self._mode_attribute, self._attr_preset_modes.index(preset_mode) + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self.async_set_homee_value(self._speed_attribute, 0) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on.""" + if preset_mode is not None: + if preset_mode != "manual": + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_preset_mode", + translation_placeholders={"preset_mode": preset_mode}, + ) + + await self.async_set_preset_mode(preset_mode) + + # If no percentage is given, use the last known value. + if percentage is None: + percentage = ranged_value_to_percentage( + self.speed_range, + self._speed_attribute.last_value, + ) + # If the last known value is 0, set 100%. + if percentage == 0: + percentage = 100 + + await self.async_set_percentage(percentage) diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index d6d327a32c5..062b530ac7e 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -11,6 +11,19 @@ } } }, + "fan": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "mdi:hand-back-left", + "auto": "mdi:auto-mode", + "summer": "mdi:sun-thermometer-outline" + } + } + } + } + }, "sensor": { "brightness": { "default": "mdi:brightness-5" diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index e65b73b4a67..ab1d5bd4f49 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -6,7 +6,10 @@ from dataclasses import dataclass from pyHomee.const import AttributeType, NodeState from pyHomee.model import HomeeAttribute, HomeeNode +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -14,10 +17,17 @@ from homeassistant.components.sensor import ( ) 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 . import HomeeConfigEntry from .const import ( + DOMAIN, HOMEE_UNIT_TO_HA_UNIT, OPEN_CLOSE_MAP, OPEN_CLOSE_MAP_REVERSED, @@ -274,14 +284,55 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = ( ) +def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: + """Get list of related automations and scripts.""" + used_in = automations_with_entity(hass, entity_id) + used_in += scripts_with_entity(hass, entity_id) + return used_in + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_devices: AddConfigEntryEntitiesCallback, ) -> None: """Add the homee platform for the sensor components.""" - + ent_reg = er.async_get(hass) devices: list[HomeeSensor | HomeeNodeSensor] = [] + + def add_deprecated_entity( + attribute: HomeeAttribute, description: HomeeSensorEntityDescription + ) -> None: + """Add deprecated entities.""" + entity_uid = f"{config_entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}" + if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, entity_uid): + entity_entry = ent_reg.async_get(entity_id) + if entity_entry and entity_entry.disabled: + ent_reg.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"deprecated_entity_{entity_uid}", + ) + elif entity_entry: + devices.append(HomeeSensor(attribute, config_entry, description)) + if entity_used_in(hass, entity_id): + async_create_issue( + hass, + DOMAIN, + f"deprecated_entity_{entity_uid}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity", + translation_placeholders={ + "name": str( + entity_entry.name or entity_entry.original_name + ), + "entity": entity_id, + }, + ) + for node in config_entry.runtime_data.nodes: # Node properties that are sensors. devices.extend( @@ -290,11 +341,15 @@ async def async_setup_entry( ) # Node attributes that are sensors. - devices.extend( - HomeeSensor(attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]) - for attribute in node.attributes - if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable - ) + for attribute in node.attributes: + if attribute.type == AttributeType.CURRENT_VALVE_POSITION: + add_deprecated_entity(attribute, SENSOR_DESCRIPTIONS[attribute.type]) + elif attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable: + devices.append( + HomeeSensor( + attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type] + ) + ) if devices: async_add_devices(devices) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index f8d83a3073e..5e124aa427e 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -26,6 +26,11 @@ } }, "entity": { + "alarm_control_panel": { + "homee_mode": { + "name": "Status" + } + }, "binary_sensor": { "blackout_alarm": { "name": "Blackout" @@ -142,6 +147,40 @@ } } }, + "event": { + "up_down_remote": { + "name": "Up/down remote", + "state_attributes": { + "event_type": { + "state": { + "release": "Released", + "up": "Up", + "down": "Down", + "stop": "Stop", + "up_long": "Up (long press)", + "down_long": "Down (long press)", + "stop_long": "Stop (long press)", + "c_button": "C button", + "b_button": "B button", + "a_button": "A button" + } + } + } + } + }, + "fan": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "[%key:common::state::manual%]", + "auto": "[%key:common::state::auto%]", + "summer": "Summer" + } + } + } + } + }, "light": { "light_instance": { "name": "Light {instance}" @@ -356,6 +395,18 @@ "exceptions": { "connection_closed": { "message": "Could not connect to homee while setting attribute." + }, + "disarm_not_supported": { + "message": "Disarm is not supported by homee." + }, + "invalid_preset_mode": { + "message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'." + } + }, + "issues": { + "deprecated_entity": { + "title": "The Homee {name} entity is deprecated", + "description": "The Homee entity `{entity}` is deprecated and will be removed in release 2025.12.\nThe valve is available directly in the respective climate entity.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index ae682a0ea2d..44f18c30099 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -24,6 +24,7 @@ VIDEO_CODEC_LIBX264 = "libx264" AUDIO_CODEC_OPUS = "libopus" VIDEO_CODEC_H264_OMX = "h264_omx" VIDEO_CODEC_H264_V4L2M2M = "h264_v4l2m2m" +VIDEO_CODEC_H264_QSV = "h264_qsv" # Intel Quick Sync Video VIDEO_PROFILE_NAMES = ["baseline", "main", "high"] AUDIO_CODEC_COPY = "copy" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index dcdf6892dc2..e6507c4a912 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -39,7 +39,7 @@ "camera_copy": "Cameras that support native H.264 streams", "camera_audio": "Cameras that support audio" }, - "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", + "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single-board computers.", "title": "Camera configuration" }, "advanced": { diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 4dda495ce77..f21bf391761 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -167,6 +167,8 @@ HC_HASS_TO_HOMEKIT_FAN_STATE = { HVACAction.COOLING: FAN_STATE_ACTIVE, HVACAction.DRYING: FAN_STATE_ACTIVE, HVACAction.FAN: FAN_STATE_ACTIVE, + HVACAction.PREHEATING: FAN_STATE_IDLE, + HVACAction.DEFROSTING: FAN_STATE_IDLE, } HEAT_COOL_DEADBAND = 5 diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index bc98f00c15a..85207e09626 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -112,6 +112,7 @@ from .const import ( TYPE_VALVE, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, + VIDEO_CODEC_H264_QSV, VIDEO_CODEC_H264_V4L2M2M, VIDEO_CODEC_LIBX264, ) @@ -130,6 +131,7 @@ MAX_PORT = 65535 VALID_VIDEO_CODECS = [ VIDEO_CODEC_LIBX264, VIDEO_CODEC_H264_OMX, + VIDEO_CODEC_H264_QSV, VIDEO_CODEC_H264_V4L2M2M, AUDIO_CODEC_COPY, ] diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index e857e1a7f01..15785a3947a 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -12,7 +12,7 @@ }, "pair": { "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.", + "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", "allow_insecure_setup_codes": "Allow pairing with insecure setup codes." diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index 6e16e16ba99..28943774b6c 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -63,6 +63,11 @@ class HMThermostat(HMDevice, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = 4.5 + _attr_max_temp = 30.5 + _attr_target_temperature_step = 0.5 + + _state: str @property def hvac_mode(self) -> HVACMode: @@ -93,7 +98,7 @@ class HMThermostat(HMDevice, ClimateEntity): return [HVACMode.HEAT, HVACMode.OFF] @property - def preset_mode(self): + def preset_mode(self) -> str: """Return the current preset mode, e.g., home, away, temp.""" if self._data.get("BOOST_MODE", False): return "boost" @@ -110,7 +115,7 @@ class HMThermostat(HMDevice, ClimateEntity): return mode @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" return [ HM_PRESET_MAP[mode] @@ -119,7 +124,7 @@ class HMThermostat(HMDevice, ClimateEntity): ] @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" for node in HM_HUMI_MAP: if node in self._data: @@ -127,7 +132,7 @@ class HMThermostat(HMDevice, ClimateEntity): return None @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" for node in HM_TEMP_MAP: if node in self._data: @@ -135,7 +140,7 @@ class HMThermostat(HMDevice, ClimateEntity): return None @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the target temperature.""" return self._data.get(self._state) @@ -164,21 +169,6 @@ class HMThermostat(HMDevice, ClimateEntity): elif preset_mode == PRESET_ECO: self._hmdevice.MODE = self._hmdevice.LOWERING_MODE - @property - def min_temp(self): - """Return the minimum temperature.""" - return 4.5 - - @property - def max_temp(self): - """Return the maximum temperature.""" - return 30.5 - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return 0.5 - @property def _hm_control_mode(self): """Return Control mode.""" diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index 91ef2e90242..484ab5ada2a 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -215,31 +215,31 @@ HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = { ] } -HM_ATTRIBUTE_SUPPORT = { - "LOWBAT": ["battery", {0: "High", 1: "Low"}], - "LOW_BAT": ["battery", {0: "High", 1: "Low"}], - "ERROR": ["error", {0: "No"}], - "ERROR_SABOTAGE": ["sabotage", {0: "No", 1: "Yes"}], - "SABOTAGE": ["sabotage", {0: "No", 1: "Yes"}], - "RSSI_PEER": ["rssi_peer", {}], - "RSSI_DEVICE": ["rssi_device", {}], - "VALVE_STATE": ["valve", {}], - "LEVEL": ["level", {}], - "BATTERY_STATE": ["battery", {}], - "CONTROL_MODE": [ +HM_ATTRIBUTE_SUPPORT: dict[str, tuple[str, dict[int, str]]] = { + "LOWBAT": ("battery", {0: "High", 1: "Low"}), + "LOW_BAT": ("battery", {0: "High", 1: "Low"}), + "ERROR": ("error", {0: "No"}), + "ERROR_SABOTAGE": ("sabotage", {0: "No", 1: "Yes"}), + "SABOTAGE": ("sabotage", {0: "No", 1: "Yes"}), + "RSSI_PEER": ("rssi_peer", {}), + "RSSI_DEVICE": ("rssi_device", {}), + "VALVE_STATE": ("valve", {}), + "LEVEL": ("level", {}), + "BATTERY_STATE": ("battery", {}), + "CONTROL_MODE": ( "mode", {0: "Auto", 1: "Manual", 2: "Away", 3: "Boost", 4: "Comfort", 5: "Lowering"}, - ], - "POWER": ["power", {}], - "CURRENT": ["current", {}], - "VOLTAGE": ["voltage", {}], - "OPERATING_VOLTAGE": ["voltage", {}], - "WORKING": ["working", {0: "No", 1: "Yes"}], - "STATE_UNCERTAIN": ["state_uncertain", {}], - "SENDERID": ["last_senderid", {}], - "SENDERADDRESS": ["last_senderaddress", {}], - "ERROR_ALARM_TEST": ["error_alarm_test", {0: "No", 1: "Yes"}], - "ERROR_SMOKE_CHAMBER": ["error_smoke_chamber", {0: "No", 1: "Yes"}], + ), + "POWER": ("power", {}), + "CURRENT": ("current", {}), + "VOLTAGE": ("voltage", {}), + "OPERATING_VOLTAGE": ("voltage", {}), + "WORKING": ("working", {0: "No", 1: "Yes"}), + "STATE_UNCERTAIN": ("state_uncertain", {}), + "SENDERID": ("last_senderid", {}), + "SENDERADDRESS": ("last_senderaddress", {}), + "ERROR_ALARM_TEST": ("error_alarm_test", {0: "No", 1: "Yes"}), + "ERROR_SMOKE_CHAMBER": ("error_smoke_chamber", {0: "No", 1: "Yes"}), } HM_PRESS_EVENTS = [ diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 44e95e98f38..3b5d2ebb509 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -5,6 +5,7 @@ from __future__ import annotations from abc import abstractmethod from datetime import timedelta import logging +from typing import Any from pyhomematic import HMConnection from pyhomematic.devicetypes.generic import HMGeneric @@ -50,7 +51,7 @@ class HMDevice(Entity): self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._unique_id = config.get(ATTR_UNIQUE_ID) - self._data: dict[str, str] = {} + self._data: dict[str, Any] = {} self._connected = False self._available = False self._channel_map: dict[str, str] = {} @@ -99,10 +100,10 @@ class HMDevice(Entity): return attr - def update(self): + def update(self) -> None: """Connect to HomeMatic init values.""" if self._connected: - return True + return # Initialize self._homematic = self.hass.data[DATA_HOMEMATIC] diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index c59a9d788b3..e460c162398 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -3,7 +3,6 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -21,7 +20,7 @@ from .const import ( HMIPC_HAPID, HMIPC_NAME, ) -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP from .services import async_setup_services, async_unload_services CONFIG_SCHEMA = vol.Schema( @@ -45,8 +44,6 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HomematicIP Cloud component.""" - hass.data[DOMAIN] = {} - accesspoints = config.get(DOMAIN, []) for conf in accesspoints: @@ -69,7 +66,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HomematicIPConfigEntry) -> bool: """Set up an access point from a config entry.""" # 0.104 introduced config entry unique id, this makes upgrading possible @@ -81,8 +78,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hap = HomematicipHAP(hass, entry) - hass.data[DOMAIN][entry.unique_id] = hap + entry.runtime_data = hap if not await hap.async_setup(): return False @@ -110,9 +107,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: HomematicIPConfigEntry +) -> bool: """Unload a config entry.""" - hap = hass.data[DOMAIN].pop(entry.unique_id) + hap = entry.runtime_data + assert hap.reset_connection_listener is not None hap.reset_connection_listener() await async_unload_services(hass) @@ -122,7 +122,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_remove_obsolete_entities( - hass: HomeAssistant, entry: ConfigEntry, hap: HomematicipHAP + hass: HomeAssistant, entry: HomematicIPConfigEntry, hap: HomematicipHAP ): """Remove obsolete entities from entity registry.""" diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index af57d8b0cd0..ddfe10fba54 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -11,13 +11,12 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .hap import AsyncHome, HomematicipHAP +from .hap import AsyncHome, HomematicIPConfigEntry, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -26,11 +25,11 @@ CONST_ALARM_CONTROL_PANEL_NAME = "HmIP Alarm Control Panel" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP alrm control panel from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data async_add_entities([HomematicipAlarmControlPanelEntity(hap)]) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index e135e95634d..9c0e5620022 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -34,14 +34,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode" ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" @@ -75,11 +74,11 @@ SAM_DEVICE_ATTRIBUTES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud binary sensor from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)] for device in hap.home.devices: if isinstance(device, AccelerationSensor): diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index 0d70ad53d54..31fa2c889ac 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -5,22 +5,20 @@ from __future__ import annotations from homematicip.device import WallMountedGarageDoorController from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP button from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data async_add_entities( HomematicipGarageDoorControllerButton(hap, device) diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 0952f17d3ec..7f393cf52bd 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -24,7 +24,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -32,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP HEATING_PROFILES = {"PROFILE_1": 0, "PROFILE_2": 1, "PROFILE_3": 2} COOLING_PROFILES = {"PROFILE_4": 3, "PROFILE_5": 4, "PROFILE_6": 5} @@ -55,11 +54,11 @@ HMIP_ECO_CM = "ECO" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP climate from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data async_add_entities( HomematicipHeatingGroup(hap, device) diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 317024658e1..f9986e0c526 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -21,13 +21,11 @@ from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP HMIP_COVER_OPEN = 0 HMIP_COVER_CLOSED = 1 @@ -37,11 +35,11 @@ HMIP_SLATS_CLOSED = 1 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP cover from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [ HomematicipCoverShutterGroup(hap, group) for group in hap.home.groups diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index fc7f43bad1a..101c3e3015a 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -13,13 +13,11 @@ from homeassistant.components.event import ( EventEntity, EventEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP @dataclass(frozen=True, kw_only=True) @@ -44,11 +42,11 @@ EVENT_DESCRIPTIONS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP cover from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] entities.extend( diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 6f98836a1ff..f3681a89110 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -25,6 +25,8 @@ from .errors import HmipcConnectionError _LOGGER = logging.getLogger(__name__) +type HomematicIPConfigEntry = ConfigEntry[HomematicipHAP] + async def build_context_async( hass: HomeAssistant, hapid: str | None, authtoken: str | None @@ -102,12 +104,15 @@ class HomematicipHAP: home: AsyncHome - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: HomematicIPConfigEntry + ) -> None: """Initialize HomematicIP Cloud connection.""" self.hass = hass self.config_entry = config_entry self._ws_close_requested = False + self._ws_connection_closed = asyncio.Event() self._retry_task: asyncio.Task | None = None self._tries = 0 self._accesspoint_connected = True @@ -214,6 +219,8 @@ class HomematicipHAP: try: await self.home.get_current_state_async() hmip_events = self.home.enable_events() + self.home.set_on_connected_handler(self.ws_connected_handler) + self.home.set_on_disconnected_handler(self.ws_disconnected_handler) tries = 0 await hmip_events except HmipConnectionError: @@ -263,6 +270,18 @@ class HomematicipHAP: "Reset connection to access point id %s", self.config_entry.unique_id ) + async def ws_connected_handler(self) -> None: + """Handle websocket connected.""" + _LOGGER.debug("WebSocket connection to HomematicIP established") + if self._ws_connection_closed.is_set(): + await self.get_state() + self._ws_connection_closed.clear() + + async def ws_disconnected_handler(self) -> None: + """Handle websocket disconnection.""" + _LOGGER.warning("WebSocket connection to HomematicIP closed") + self._ws_connection_closed.set() + async def get_hap( self, hass: HomeAssistant, @@ -286,6 +305,7 @@ class HomematicipHAP: raise HmipcConnectionError from err home.on_update(self.async_update) home.on_create(self.async_create_entity) + hass.loop.create_task(self.async_connect()) return home diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 338599b9a14..d5175e6e647 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -4,16 +4,16 @@ from __future__ import annotations from typing import Any -from homematicip.base.enums import OpticalSignalBehaviour, RGBColorState +from homematicip.base.enums import DeviceType, OpticalSignalBehaviour, RGBColorState from homematicip.base.functionalChannels import NotificationLightChannel from homematicip.device import ( BrandDimmer, - BrandSwitchMeasuring, BrandSwitchNotificationLight, Dimmer, DinRailDimmer3, FullFlushDimmer, PluggableDimmer, + SwitchMeasuring, WiredDimmer3, ) from packaging.version import Version @@ -28,27 +28,28 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud lights from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, BrandSwitchMeasuring): + if ( + isinstance(device, SwitchMeasuring) + and getattr(device, "deviceType", None) == DeviceType.BRAND_SWITCH_MEASURING + ): entities.append(HomematicipLightMeasuring(hap, device)) - elif isinstance(device, BrandSwitchNotificationLight): + if isinstance(device, BrandSwitchNotificationLight): device_version = Version(device.firmwareVersion) entities.append(HomematicipLight(hap, device)) diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py index 04461682f8d..bae075e1a17 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -9,12 +9,11 @@ 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 from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity +from .hap import HomematicIPConfigEntry from .helpers import handle_errors _LOGGER = logging.getLogger(__name__) @@ -36,11 +35,11 @@ DEVICE_DLD_ATTRIBUTES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP locks from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data async_add_entities( HomematicipDoorLockDrive(hap, device) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 15bc24c110f..fc4a1cb831f 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==2.0.1.1"] + "requirements": ["homematicip==2.0.4"] } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index ba739273788..13f3694de7a 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -11,12 +11,10 @@ from homematicip.base.functionalChannels import ( FunctionalChannel, ) from homematicip.device import ( - BrandSwitchMeasuring, EnergySensorsInterface, FloorTerminalBlock6, FloorTerminalBlock10, FloorTerminalBlock12, - FullFlushSwitchMeasuring, HeatingThermostat, HeatingThermostatCompact, HeatingThermostatEvo, @@ -26,9 +24,9 @@ from homematicip.device import ( MotionDetectorOutdoor, MotionDetectorPushButton, PassageDetector, - PlugableSwitchMeasuring, PresenceDetectorIndoor, RoomControlDeviceAnalog, + SwitchMeasuring, TemperatureDifferenceSensor2, TemperatureHumiditySensorDisplay, TemperatureHumiditySensorOutdoor, @@ -44,7 +42,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, LIGHT_LUX, @@ -61,9 +58,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP from .helpers import get_channels_from_device ATTR_CURRENT_ILLUMINATION = "current_illumination" @@ -96,11 +92,11 @@ ILLUMINATION_DEVICE_ATTRIBUTES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud sensors from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, HomeControlAccessPoint): @@ -145,14 +141,7 @@ async def async_setup_entry( ), ): entities.append(HomematicipIlluminanceSensor(hap, device)) - if isinstance( - device, - ( - PlugableSwitchMeasuring, - BrandSwitchMeasuring, - FullFlushSwitchMeasuring, - ), - ): + if isinstance(device, SwitchMeasuring): entities.append(HomematicipPowerSensor(hap, device)) entities.append(HomematicipEnergySensor(hap, device)) if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 4518c7736eb..2e76a0b7aac 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -22,6 +22,7 @@ from homeassistant.helpers.service import ( ) from .const import DOMAIN +from .hap import HomematicIPConfigEntry _LOGGER = logging.getLogger(__name__) @@ -218,7 +219,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def async_unload_services(hass: HomeAssistant): """Unload HomematicIP Cloud services.""" - if hass.data[DOMAIN]: + if hass.config_entries.async_loaded_entries(DOMAIN): return for hmipc_service in HMIPC_SERVICES: @@ -235,8 +236,9 @@ async def _async_activate_eco_mode_with_duration( if home := _get_home(hass, hapid): await home.activate_absence_with_duration_async(duration) else: - for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_duration_async(duration) + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.activate_absence_with_duration_async(duration) async def _async_activate_eco_mode_with_period( @@ -249,8 +251,9 @@ async def _async_activate_eco_mode_with_period( if home := _get_home(hass, hapid): await home.activate_absence_with_period_async(endtime) else: - for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_period_async(endtime) + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.activate_absence_with_period_async(endtime) async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: @@ -262,8 +265,9 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> if home := _get_home(hass, hapid): await home.activate_vacation_async(endtime, temperature) else: - for hap in hass.data[DOMAIN].values(): - await hap.home.activate_vacation_async(endtime, temperature) + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.activate_vacation_async(endtime, temperature) async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) -> None: @@ -272,8 +276,9 @@ async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) if home := _get_home(hass, hapid): await home.deactivate_absence_async() else: - for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_absence_async() + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.deactivate_absence_async() async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: @@ -282,8 +287,9 @@ async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) if home := _get_home(hass, hapid): await home.deactivate_vacation_async() else: - for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_vacation_async() + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.deactivate_vacation_async() async def _set_active_climate_profile( @@ -293,14 +299,15 @@ async def _set_active_climate_profile( entity_id_list = service.data[ATTR_ENTITY_ID] climate_profile_index = service.data[ATTR_CLIMATE_PROFILE_INDEX] - 1 - for hap in hass.data[DOMAIN].values(): + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): if entity_id_list != "all": for entity_id in entity_id_list: - group = hap.hmip_device_by_entity_id.get(entity_id) + group = entry.runtime_data.hmip_device_by_entity_id.get(entity_id) if group and isinstance(group, HeatingGroup): await group.set_active_profile_async(climate_profile_index) else: - for group in hap.home.groups: + for group in entry.runtime_data.home.groups: if isinstance(group, HeatingGroup): await group.set_active_profile_async(climate_profile_index) @@ -313,8 +320,10 @@ async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> N config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] anonymize = service.data[ATTR_ANONYMIZE] - for hap in hass.data[DOMAIN].values(): - hap_sgtin = hap.config_entry.unique_id + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + hap_sgtin = entry.unique_id + assert hap_sgtin is not None if anonymize: hap_sgtin = hap_sgtin[-4:] @@ -323,7 +332,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_async() + json_state = await entry.runtime_data.home.download_configuration_async() json_state = handle_config(json_state, anonymize) config_file.write_text(json_state, encoding="utf8") @@ -333,14 +342,15 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall) """Service to reset the energy counter.""" entity_id_list = service.data[ATTR_ENTITY_ID] - for hap in hass.data[DOMAIN].values(): + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): if entity_id_list != "all": for entity_id in entity_id_list: - device = hap.hmip_device_by_entity_id.get(entity_id) + device = entry.runtime_data.hmip_device_by_entity_id.get(entity_id) if device and isinstance(device, SwitchMeasuring): await device.reset_energy_counter_async() else: - for device in hap.home.devices: + for device in entry.runtime_data.home.devices: if isinstance(device, SwitchMeasuring): await device.reset_energy_counter_async() @@ -353,14 +363,17 @@ async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall if home := _get_home(hass, hapid): await home.set_cooling_async(cooling) else: - for hap in hass.data[DOMAIN].values(): - await hap.home.set_cooling_async(cooling) + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.set_cooling_async(cooling) def _get_home(hass: HomeAssistant, hapid: str) -> AsyncHome | None: """Return a HmIP home.""" - if hap := hass.data[DOMAIN].get(hapid): - return hap.home + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if entry.unique_id == hapid: + return entry.runtime_data.home raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 2de02fb22a5..66a40229c7e 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,53 +4,48 @@ from __future__ import annotations from typing import Any +from homematicip.base.enums import DeviceType from homematicip.device import ( BrandSwitch2, - BrandSwitchMeasuring, DinRailSwitch, DinRailSwitch4, FullFlushInputSwitch, - FullFlushSwitchMeasuring, HeatingSwitch2, MultiIOBox, OpenCollector8Module, PlugableSwitch, - PlugableSwitchMeasuring, PrintedCircuitBoardSwitch2, PrintedCircuitBoardSwitchBattery, + SwitchMeasuring, WiredSwitch8, ) from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import ATTR_GROUP_MEMBER_UNREACHABLE, HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP switch from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [ HomematicipGroupSwitch(hap, group) for group in hap.home.groups if isinstance(group, (ExtendedLinkedSwitchingGroup, SwitchingGroup)) ] for device in hap.home.devices: - 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, (PlugableSwitchMeasuring, FullFlushSwitchMeasuring)): + if ( + isinstance(device, SwitchMeasuring) + and getattr(device, "deviceType", None) != DeviceType.BRAND_SWITCH_MEASURING + ): entities.append(HomematicipSwitchMeasuring(hap, device)) elif isinstance(device, WiredSwitch8): entities.extend( diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 78e86ec652c..061f6642bb2 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -18,14 +18,12 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, WeatherEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP HOME_WEATHER_CONDITION = { WeatherCondition.CLEAR: ATTR_CONDITION_SUNNY, @@ -48,11 +46,11 @@ HOME_WEATHER_CONDITION = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP weather sensor from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, WeatherSensorPro): diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index 75fdeb4f8cc..4beea27374a 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping from dataclasses import dataclass import logging from typing import Any @@ -58,6 +57,8 @@ SERVICE_SEND_COMMAND_SCHEMA = vol.Schema( } ) +type HomeworksConfigEntry = ConfigEntry[HomeworksData] + @dataclass class HomeworksData: @@ -72,45 +73,44 @@ class HomeworksData: def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Lutron Homeworks Series 4 and 8 integration.""" - async def async_call_service(service_call: ServiceCall) -> None: - """Call the service.""" - await async_send_command(hass, service_call.data) - hass.services.async_register( DOMAIN, "send_command", - async_call_service, + async_send_command, schema=SERVICE_SEND_COMMAND_SCHEMA, ) -async def async_send_command(hass: HomeAssistant, data: Mapping[str, Any]) -> None: +async def async_send_command(service_call: ServiceCall) -> None: """Send command to a controller.""" def get_controller_ids() -> list[str]: """Get homeworks data for the specified controller ID.""" - return [data.controller_id for data in hass.data[DOMAIN].values()] + return [ + entry.runtime_data.controller_id + for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN) + ] def get_homeworks_data(controller_id: str) -> HomeworksData | None: """Get homeworks data for the specified controller ID.""" - data: HomeworksData - for data in hass.data[DOMAIN].values(): - if data.controller_id == controller_id: - return data + entry: HomeworksConfigEntry + for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN): + if entry.runtime_data.controller_id == controller_id: + return entry.runtime_data return None - homeworks_data = get_homeworks_data(data[CONF_CONTROLLER_ID]) + homeworks_data = get_homeworks_data(service_call.data[CONF_CONTROLLER_ID]) if not homeworks_data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_controller_id", translation_placeholders={ - "controller_id": data[CONF_CONTROLLER_ID], + "controller_id": service_call.data[CONF_CONTROLLER_ID], "controller_ids": ",".join(get_controller_ids()), }, ) - commands = data[CONF_COMMAND] + commands = service_call.data[CONF_COMMAND] _LOGGER.debug("Send commands: %s", commands) for command in commands: if command.lower().startswith("delay"): @@ -119,7 +119,7 @@ async def async_send_command(hass: HomeAssistant, data: Mapping[str, Any]) -> No await asyncio.sleep(delay / 1000) else: _LOGGER.debug("Sending command '%s'", command) - await hass.async_add_executor_job( + await service_call.hass.async_add_executor_job( homeworks_data.controller._send, # noqa: SLF001 command, ) @@ -132,10 +132,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HomeworksConfigEntry) -> bool: """Set up Homeworks from a config entry.""" - hass.data.setdefault(DOMAIN, {}) controller_id = entry.options[CONF_CONTROLLER_ID] def hw_callback(msg_type: Any, values: Any) -> None: @@ -174,9 +173,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name = key_config[CONF_NAME] keypads[addr] = HomeworksKeypad(hass, controller, controller_id, addr, name) - hass.data[DOMAIN][entry.entry_id] = HomeworksData( - controller, controller_id, keypads - ) + entry.runtime_data = HomeworksData(controller, controller_id, keypads) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -184,19 +181,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HomeworksConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: HomeworksData = hass.data[DOMAIN].pop(entry.entry_id) - for keypad in data.keypads.values(): + for keypad in entry.runtime_data.keypads.values(): keypad.unsubscribe() - await hass.async_add_executor_job(data.controller.stop) + await hass.async_add_executor_job(entry.runtime_data.controller.stop) return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: HomeworksConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/homeworks/binary_sensor.py b/homeassistant/components/homeworks/binary_sensor.py index 9bdea75479d..9c2b2e12bc2 100644 --- a/homeassistant/components/homeworks/binary_sensor.py +++ b/homeassistant/components/homeworks/binary_sensor.py @@ -8,14 +8,13 @@ from typing import Any from pyhomeworks.pyhomeworks import HW_KEYPAD_LED_CHANGED, Homeworks from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME 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 HomeworksData, HomeworksKeypad +from . import HomeworksConfigEntry, HomeworksKeypad from .const import ( CONF_ADDR, CONF_BUTTONS, @@ -32,11 +31,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HomeworksConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homeworks binary sensors.""" - data: HomeworksData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data controller = data.controller controller_id = entry.options[CONF_CONTROLLER_ID] entities = [] diff --git a/homeassistant/components/homeworks/button.py b/homeassistant/components/homeworks/button.py index d76c18985e9..47c92a323ee 100644 --- a/homeassistant/components/homeworks/button.py +++ b/homeassistant/components/homeworks/button.py @@ -7,13 +7,12 @@ import asyncio from pyhomeworks.pyhomeworks import Homeworks from homeassistant.components.button import ButtonEntity -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 . import HomeworksData +from . import HomeworksConfigEntry from .const import ( CONF_ADDR, CONF_BUTTONS, @@ -28,12 +27,11 @@ from .entity import HomeworksEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HomeworksConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homeworks buttons.""" - data: HomeworksData = hass.data[DOMAIN][entry.entry_id] - controller = data.controller + controller = entry.runtime_data.controller controller_id = entry.options[CONF_CONTROLLER_ID] entities = [] for keypad in entry.options.get(CONF_KEYPADS, []): diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py index f07758bbace..a9ed35f859e 100644 --- a/homeassistant/components/homeworks/light.py +++ b/homeassistant/components/homeworks/light.py @@ -8,14 +8,13 @@ from typing import Any from pyhomeworks.pyhomeworks import HW_LIGHT_CHANGED, Homeworks from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME 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 HomeworksData +from . import HomeworksConfigEntry from .const import CONF_ADDR, CONF_CONTROLLER_ID, CONF_DIMMERS, CONF_RATE, DOMAIN from .entity import HomeworksEntity @@ -24,12 +23,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HomeworksConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homeworks lights.""" - data: HomeworksData = hass.data[DOMAIN][entry.entry_id] - controller = data.controller + controller = entry.runtime_data.controller controller_id = entry.options[CONF_CONTROLLER_ID] entities = [] for dimmer in entry.options.get(CONF_DIMMERS, []): diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index 1adc21be09f..19a0a5d1c55 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -27,7 +27,8 @@ def require_admin[ ]( _func: None = None, *, - error: Unauthorized | None = None, + perm_category: str | None = None, + permission: str | None = None, ) -> Callable[ [_FuncType[_HomeAssistantViewT, _P, _ResponseT]], _FuncType[_HomeAssistantViewT, _P, _ResponseT], @@ -51,7 +52,8 @@ def require_admin[ ]( _func: _FuncType[_HomeAssistantViewT, _P, _ResponseT] | None = None, *, - error: Unauthorized | None = None, + perm_category: str | None = None, + permission: str | None = None, ) -> ( Callable[ [_FuncType[_HomeAssistantViewT, _P, _ResponseT]], @@ -76,7 +78,7 @@ def require_admin[ """Check admin and call function.""" user: User = request["hass_user"] if not user.is_admin: - raise error or Unauthorized() + raise Unauthorized(perm_category=perm_category, permission=permission) return await func(self, request, *args, **kwargs) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index be9d02e45fd..6126968eab6 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -23,7 +23,6 @@ from huawei_lte_api.exceptions import ( from requests.exceptions import Timeout import voluptuous as vol -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_HW_VERSION, @@ -90,36 +89,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=30) -NOTIFY_SCHEMA = vol.Any( - None, - vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_RECIPIENT): vol.Any( - None, vol.All(cv.ensure_list, [cv.string]) - ), - } - ), -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_URL): cv.url, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA, - } - ) - ], - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_URL): cv.url}) diff --git a/homeassistant/components/huawei_lte/icons.json b/homeassistant/components/huawei_lte/icons.json index 22eb345eba5..862daa47cde 100644 --- a/homeassistant/components/huawei_lte/icons.json +++ b/homeassistant/components/huawei_lte/icons.json @@ -37,6 +37,137 @@ "default": "mdi:antenna" } }, + "sensor": { + "uptime": { + "default": "mdi:timer-outline" + }, + "wan_ip_address": { + "default": "mdi:ip" + }, + "wan_ipv6_address": { + "default": "mdi:ip" + }, + "cell_id": { + "default": "mdi:antenna" + }, + "cqi0": { + "default": "mdi:speedometer" + }, + "cqi1": { + "default": "mdi:speedometer" + }, + "enodeb_id": { + "default": "mdi:antenna" + }, + "lac": { + "default": "mdi:map-marker" + }, + "nei_cellid": { + "default": "mdi:antenna" + }, + "nrcqi0": { + "default": "mdi:speedometer" + }, + "nrcqi1": { + "default": "mdi:speedometer" + }, + "pci": { + "default": "mdi:antenna" + }, + "rac": { + "default": "mdi:map-marker" + }, + "tac": { + "default": "mdi:map-marker" + }, + "sms_unread": { + "default": "mdi:email-arrow-left" + }, + "current_day_transfer": { + "default": "mdi:arrow-up-down-bold" + }, + "current_month_download": { + "default": "mdi:download" + }, + "current_month_upload": { + "default": "mdi:upload" + }, + "wifi_clients_connected": { + "default": "mdi:wifi" + }, + "primary_dns_server": { + "default": "mdi:ip" + }, + "primary_ipv6_dns_server": { + "default": "mdi:ip" + }, + "secondary_dns_server": { + "default": "mdi:ip" + }, + "secondary_ipv6_dns_server": { + "default": "mdi:ip" + }, + "current_connection_duration": { + "default": "mdi:timer-outline" + }, + "current_connection_download": { + "default": "mdi:download" + }, + "current_download_rate": { + "default": "mdi:download" + }, + "current_connection_upload": { + "default": "mdi:upload" + }, + "current_upload_rate": { + "default": "mdi:upload" + }, + "total_connected_duration": { + "default": "mdi:timer-outline" + }, + "total_download": { + "default": "mdi:download" + }, + "total_upload": { + "default": "mdi:upload" + }, + "sms_deleted_device": { + "default": "mdi:email-minus" + }, + "sms_drafts_device": { + "default": "mdi:email-arrow-right-outline" + }, + "sms_inbox_device": { + "default": "mdi:email" + }, + "sms_capacity_device": { + "default": "mdi:email" + }, + "sms_outbox_device": { + "default": "mdi:email-arrow-right" + }, + "sms_unread_device": { + "default": "mdi:email-arrow-left" + }, + "sms_drafts_sim": { + "default": "mdi:email-arrow-right-outline" + }, + "sms_inbox_sim": { + "default": "mdi:email" + }, + "sms_capacity_sim": { + "default": "mdi:email" + }, + "sms_outbox_sim": { + "default": "mdi:email-arrow-right" + }, + "sms_unread_sim": { + "default": "mdi:email-arrow-left" + }, + "sms_messages_sim": { + "default": "mdi:email-arrow-left" + } + }, "switch": { "mobile_data": { "default": "mdi:signal-off", diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index e9270dfd6ff..003ba1f9823 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -138,7 +138,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "uptime": HuaweiSensorEntityDescription( key="uptime", translation_key="uptime", - icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -146,14 +145,12 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "WanIPAddress": HuaweiSensorEntityDescription( key="WanIPAddress", translation_key="wan_ip_address", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=True, ), "WanIPv6Address": HuaweiSensorEntityDescription( key="WanIPv6Address", translation_key="wan_ipv6_address", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), }, @@ -181,19 +178,16 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "cell_id": HuaweiSensorEntityDescription( key="cell_id", translation_key="cell_id", - icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi0": HuaweiSensorEntityDescription( key="cqi0", translation_key="cqi0", - icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi1": HuaweiSensorEntityDescription( key="cqi1", translation_key="cqi1", - icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "dl_mcs": HuaweiSensorEntityDescription( @@ -230,7 +224,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "enodeb_id": HuaweiSensorEntityDescription( key="enodeb_id", translation_key="enodeb_id", - icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "ims": HuaweiSensorEntityDescription( @@ -241,7 +234,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "lac": HuaweiSensorEntityDescription( key="lac", translation_key="lac", - icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "ltedlfreq": HuaweiSensorEntityDescription( @@ -279,7 +271,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "nei_cellid": HuaweiSensorEntityDescription( key="nei_cellid", translation_key="nei_cellid", - icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "nrbler": HuaweiSensorEntityDescription( @@ -290,13 +281,11 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "nrcqi0": HuaweiSensorEntityDescription( key="nrcqi0", translation_key="nrcqi0", - icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "nrcqi1": HuaweiSensorEntityDescription( key="nrcqi1", translation_key="nrcqi1", - icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "nrdlbandwidth": HuaweiSensorEntityDescription( @@ -376,7 +365,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "pci": HuaweiSensorEntityDescription( key="pci", translation_key="pci", - icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "plmn": HuaweiSensorEntityDescription( @@ -387,7 +375,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "rac": HuaweiSensorEntityDescription( key="rac", translation_key="rac", - icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "rrc_status": HuaweiSensorEntityDescription( @@ -458,7 +445,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "tac": HuaweiSensorEntityDescription( key="tac", translation_key="tac", - icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "tdd": HuaweiSensorEntityDescription( @@ -522,7 +508,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "UnreadMessage": HuaweiSensorEntityDescription( key="UnreadMessage", translation_key="sms_unread", - icon="mdi:email-arrow-left", ), }, ), @@ -536,7 +521,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_day_transfer", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:arrow-up-down-bold", state_class=SensorStateClass.TOTAL, last_reset_item="CurrentDayDuration", last_reset_format_fn=format_last_reset_elapsed_seconds, @@ -546,7 +530,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_month_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:download", state_class=SensorStateClass.TOTAL, last_reset_item="MonthDuration", last_reset_format_fn=format_last_reset_elapsed_seconds, @@ -556,7 +539,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_month_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:upload", state_class=SensorStateClass.TOTAL, last_reset_item="MonthDuration", last_reset_format_fn=format_last_reset_elapsed_seconds, @@ -580,32 +562,27 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "CurrentWifiUser": HuaweiSensorEntityDescription( key="CurrentWifiUser", translation_key="wifi_clients_connected", - icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), "PrimaryDns": HuaweiSensorEntityDescription( key="PrimaryDns", translation_key="primary_dns_server", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "PrimaryIPv6Dns": HuaweiSensorEntityDescription( key="PrimaryIPv6Dns", translation_key="primary_ipv6_dns_server", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "SecondaryDns": HuaweiSensorEntityDescription( key="SecondaryDns", translation_key="secondary_dns_server", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "SecondaryIPv6Dns": HuaweiSensorEntityDescription( key="SecondaryIPv6Dns", translation_key="secondary_ipv6_dns_server", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), }, @@ -618,14 +595,12 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_connection_duration", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, - icon="mdi:timer-outline", ), "CurrentDownload": HuaweiSensorEntityDescription( key="CurrentDownload", translation_key="current_connection_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:download", state_class=SensorStateClass.TOTAL_INCREASING, ), "CurrentDownloadRate": HuaweiSensorEntityDescription( @@ -633,7 +608,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_download_rate", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:download", state_class=SensorStateClass.MEASUREMENT, ), "CurrentUpload": HuaweiSensorEntityDescription( @@ -641,7 +615,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_connection_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:upload", state_class=SensorStateClass.TOTAL_INCREASING, ), "CurrentUploadRate": HuaweiSensorEntityDescription( @@ -649,7 +622,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_upload_rate", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:upload", state_class=SensorStateClass.MEASUREMENT, ), "TotalConnectTime": HuaweiSensorEntityDescription( @@ -657,7 +629,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="total_connected_duration", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, - icon="mdi:timer-outline", state_class=SensorStateClass.TOTAL_INCREASING, ), "TotalDownload": HuaweiSensorEntityDescription( @@ -665,7 +636,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="total_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:download", state_class=SensorStateClass.TOTAL_INCREASING, ), "TotalUpload": HuaweiSensorEntityDescription( @@ -673,7 +643,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="total_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:upload", state_class=SensorStateClass.TOTAL_INCREASING, ), }, @@ -719,62 +688,50 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "LocalDeleted": HuaweiSensorEntityDescription( key="LocalDeleted", translation_key="sms_deleted_device", - icon="mdi:email-minus", ), "LocalDraft": HuaweiSensorEntityDescription( key="LocalDraft", translation_key="sms_drafts_device", - icon="mdi:email-arrow-right-outline", ), "LocalInbox": HuaweiSensorEntityDescription( key="LocalInbox", translation_key="sms_inbox_device", - icon="mdi:email", ), "LocalMax": HuaweiSensorEntityDescription( key="LocalMax", translation_key="sms_capacity_device", - icon="mdi:email", ), "LocalOutbox": HuaweiSensorEntityDescription( key="LocalOutbox", translation_key="sms_outbox_device", - icon="mdi:email-arrow-right", ), "LocalUnread": HuaweiSensorEntityDescription( key="LocalUnread", translation_key="sms_unread_device", - icon="mdi:email-arrow-left", ), "SimDraft": HuaweiSensorEntityDescription( key="SimDraft", translation_key="sms_drafts_sim", - icon="mdi:email-arrow-right-outline", ), "SimInbox": HuaweiSensorEntityDescription( key="SimInbox", translation_key="sms_inbox_sim", - icon="mdi:email", ), "SimMax": HuaweiSensorEntityDescription( key="SimMax", translation_key="sms_capacity_sim", - icon="mdi:email", ), "SimOutbox": HuaweiSensorEntityDescription( key="SimOutbox", translation_key="sms_outbox_sim", - icon="mdi:email-arrow-right", ), "SimUnread": HuaweiSensorEntityDescription( key="SimUnread", translation_key="sms_unread_sim", - icon="mdi:email-arrow-left", ), "SimUsed": HuaweiSensorEntityDescription( key="SimUsed", translation_key="sms_messages_sim", - icon="mdi:email-arrow-left", ), }, ), @@ -870,7 +827,7 @@ class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): """Return icon for sensor.""" if self.entity_description.icon_fn: return self.entity_description.icon_fn(self.state) - return self.entity_description.icon + return super().icon @property def device_class(self) -> SensorDeviceClass | None: diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 50879c9e166..2845338b9cf 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -61,7 +61,7 @@ }, "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.", + "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." } diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index d4c2959771b..991d7b51500 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -3,17 +3,17 @@ from aiohue.util import normalize_bridge_id from homeassistant.components import persistent_notification -from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .bridge import HueBridge +from .bridge import HueBridge, HueConfigEntry from .const import DOMAIN, SERVICE_HUE_ACTIVATE_SCENE from .migration import check_migration from .services import async_register_services -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: """Set up a bridge from a config entry.""" # check (and run) migrations if needed await check_migration(hass, entry) @@ -104,10 +104,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: """Unload a config entry.""" - unload_success = await hass.data[DOMAIN][entry.entry_id].async_reset() - if len(hass.data[DOMAIN]) == 0: - hass.data.pop(DOMAIN) + unload_success = await entry.runtime_data.async_reset() + if not hass.config_entries.async_loaded_entries(DOMAIN): hass.services.async_remove(DOMAIN, SERVICE_HUE_ACTIVATE_SCENE) return unload_success diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index ecaa6576775..1d5f10a8c91 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -2,23 +2,21 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry from .v1.binary_sensor import async_setup_entry as setup_entry_v1 from .v2.binary_sensor import async_setup_entry as setup_entry_v2 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if bridge.api_version == 1: await setup_entry_v1(hass, config_entry, async_add_entities) else: diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 5397eeebd96..5dbb894c213 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -36,11 +36,13 @@ PLATFORMS_v2 = [ Platform.SWITCH, ] +type HueConfigEntry = ConfigEntry[HueBridge] + class HueBridge: """Manages a single Hue bridge.""" - def __init__(self, hass: core.HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: core.HomeAssistant, config_entry: HueConfigEntry) -> None: """Initialize the system.""" self.config_entry = config_entry self.hass = hass @@ -58,7 +60,7 @@ class HueBridge: else: self.api = HueBridgeV2(self.host, app_key) # store (this) bridge object in hass data - hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self + self.config_entry.runtime_data = self @property def host(self) -> str: @@ -163,7 +165,7 @@ class HueBridge: ) if unload_success: - self.hass.data[DOMAIN].pop(self.config_entry.entry_id) + delattr(self.config_entry, "runtime_data") return unload_success @@ -179,7 +181,7 @@ class HueBridge: create_config_flow(self.hass, self.host) -async def _update_listener(hass: core.HomeAssistant, entry: ConfigEntry) -> None: +async def _update_listener(hass: core.HomeAssistant, entry: HueConfigEntry) -> None: """Handle ConfigEntry options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index db025922ef8..bec44352613 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -13,12 +13,7 @@ from aiohue.util import normalize_bridge_id import slugify as unicode_slug import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import ( @@ -28,6 +23,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .bridge import HueConfigEntry from .const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, @@ -53,7 +49,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: HueConfigEntry, ) -> HueV1OptionsFlowHandler | HueV2OptionsFlowHandler: """Get the options flow for this handler.""" if config_entry.data.get(CONF_API_VERSION, 1) == 1: diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index dba5aba81da..9592be69e7e 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -26,14 +26,15 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo - from .bridge import HueBridge + from .bridge import HueConfigEntry async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - if DOMAIN not in hass.data: + entries: list[HueConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + if not entries: # happens at startup return config device_id = config[CONF_DEVICE_ID] @@ -42,10 +43,10 @@ async def async_validate_trigger_config( if (device_entry := dev_reg.async_get(device_id)) is None: raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") - for conf_entry_id in device_entry.config_entries: - if conf_entry_id not in hass.data[DOMAIN]: + for entry in entries: + if entry.entry_id not in device_entry.config_entries: continue - bridge: HueBridge = hass.data[DOMAIN][conf_entry_id] + bridge = entry.runtime_data if bridge.api_version == 1: return await async_validate_trigger_config_v1(bridge, device_entry, config) return await async_validate_trigger_config_v2(bridge, device_entry, config) @@ -65,10 +66,11 @@ async def async_attach_trigger( if (device_entry := dev_reg.async_get(device_id)) is None: raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") - for conf_entry_id in device_entry.config_entries: - if conf_entry_id not in hass.data[DOMAIN]: + entry: HueConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if entry.entry_id not in device_entry.config_entries: continue - bridge: HueBridge = hass.data[DOMAIN][conf_entry_id] + bridge = entry.runtime_data if bridge.api_version == 1: return await async_attach_trigger_v1( bridge, device_entry, config, action, trigger_info @@ -85,7 +87,8 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """Get device triggers for given (hass) device id.""" - if DOMAIN not in hass.data: + entries: list[HueConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + if not entries: return [] # lookup device in HASS DeviceRegistry dev_reg: dr.DeviceRegistry = dr.async_get(hass) @@ -94,10 +97,10 @@ async def async_get_triggers( # Iterate all config entries for this device # and work out the bridge version - for conf_entry_id in device_entry.config_entries: - if conf_entry_id not in hass.data[DOMAIN]: + for entry in entries: + if entry.entry_id not in device_entry.config_entries: continue - bridge: HueBridge = hass.data[DOMAIN][conf_entry_id] + bridge = entry.runtime_data if bridge.api_version == 1: return async_get_triggers_v1(bridge, device_entry) diff --git a/homeassistant/components/hue/diagnostics.py b/homeassistant/components/hue/diagnostics.py index 6bb23d832cd..a45813151e4 100644 --- a/homeassistant/components/hue/diagnostics.py +++ b/homeassistant/components/hue/diagnostics.py @@ -4,18 +4,16 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: HueConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - bridge: HueBridge = hass.data[DOMAIN][entry.entry_id] + bridge = entry.runtime_data if bridge.api_version == 1: # diagnostics is only implemented for V2 bridges. return {} diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 249f81687c0..4cffbb73a38 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -14,22 +14,21 @@ from homeassistant.components.event import ( EventEntity, EventEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DEFAULT_BUTTON_EVENT_TYPES, DEVICE_SPECIFIC_EVENT_TYPES, DOMAIN +from .bridge import HueConfigEntry +from .const import DEFAULT_BUTTON_EVENT_TYPES, DEVICE_SPECIFIC_EVENT_TYPES from .v2.entity import HueBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up event platform from Hue button resources.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api if bridge.api_version == 1: diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 9906c9bffa4..332dc6978ad 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -2,12 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry from .v1.light import async_setup_entry as setup_entry_v1 from .v2.group import async_setup_entry as setup_groups_entry_v2 from .v2.light import async_setup_entry as setup_entry_v2 @@ -15,11 +13,11 @@ from .v2.light import async_setup_entry as setup_entry_v2 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light entities.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if bridge.api_version == 1: await setup_entry_v1(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py index 1214f39d146..55edf7d5565 100644 --- a/homeassistant/components/hue/migration.py +++ b/homeassistant/components/hue/migration.py @@ -10,7 +10,6 @@ from aiohue.v2.models.resource import ResourceTypes from homeassistant import core from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, CONF_USERNAME from homeassistant.helpers import ( aiohttp_client, @@ -18,12 +17,13 @@ from homeassistant.helpers import ( entity_registry as er, ) +from .bridge import HueConfigEntry from .const import DOMAIN LOGGER = logging.getLogger(__name__) -async def check_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: +async def check_migration(hass: core.HomeAssistant, entry: HueConfigEntry) -> None: """Check if config entry needs any migration actions.""" host = entry.data[CONF_HOST] @@ -66,7 +66,7 @@ async def check_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: hass.config_entries.async_update_entry(entry, data=data) -async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: +async def handle_v2_migration(hass: core.HomeAssistant, entry: HueConfigEntry) -> None: """Perform migration of devices and entities to V2 Id's.""" host = entry.data[CONF_HOST] api_key = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 0b9eb4efbd6..5327a54fcc8 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -12,7 +12,6 @@ from aiohue.v2.models.smart_scene import SmartScene as HueSmartScene, SmartScene import voluptuous as vol from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) -from .bridge import HueBridge +from .bridge import HueBridge, HueConfigEntry from .const import DOMAIN from .v2.entity import HueBaseEntity from .v2.helpers import normalize_hue_brightness, normalize_hue_transition @@ -33,11 +32,11 @@ ATTR_BRIGHTNESS = "brightness" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up scene platform from Hue group scenes.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api if bridge.api_version == 1: diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 227742fdbab..60845c0be7a 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -2,23 +2,21 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry from .v1.sensor import async_setup_entry as setup_entry_v1 from .v2.sensor import async_setup_entry as setup_entry_v2 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if bridge.api_version == 1: await setup_entry_v1(hass, config_entry, async_add_entities) return diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py index de6da161fba..18dd19e3391 100644 --- a/homeassistant/components/hue/services.py +++ b/homeassistant/components/hue/services.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import verify_domain_control -from .bridge import HueBridge +from .bridge import HueBridge, HueConfigEntry from .const import ( ATTR_DYNAMIC, ATTR_GROUP_NAME, @@ -37,14 +37,16 @@ def async_register_services(hass: HomeAssistant) -> None: dynamic = call.data.get(ATTR_DYNAMIC, False) # Call the set scene function on each bridge + entries: list[HueConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) tasks = [ - hue_activate_scene_v1(bridge, group_name, scene_name, transition) - if bridge.api_version == 1 - else hue_activate_scene_v2( - bridge, group_name, scene_name, transition, dynamic + hue_activate_scene_v1( + entry.runtime_data, group_name, scene_name, transition ) - for bridge in hass.data[DOMAIN].values() - if isinstance(bridge, HueBridge) + if entry.runtime_data.api_version == 1 + else hue_activate_scene_v2( + entry.runtime_data, group_name, scene_name, transition, dynamic + ) + for entry in entries ] results = await asyncio.gather(*tasks) diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index b6b21686d25..33dfe02dd49 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -19,23 +19,21 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry from .v2.entity import HueBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue switch platform from Hue resources.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api if bridge.api_version == 1: diff --git a/homeassistant/components/hue/v1/binary_sensor.py b/homeassistant/components/hue/v1/binary_sensor.py index 325c4d022fa..e06d61210b8 100644 --- a/homeassistant/components/hue/v1/binary_sensor.py +++ b/homeassistant/components/hue/v1/binary_sensor.py @@ -6,16 +6,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..const import DOMAIN as HUE_DOMAIN +from ..bridge import HueConfigEntry from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor PRESENCE_NAME_FORMAT = "{} motion" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Defer binary sensor setup to the shared sensor module.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if not bridge.sensor_manager: return diff --git a/homeassistant/components/hue/v1/device_trigger.py b/homeassistant/components/hue/v1/device_trigger.py index 493c668f549..c55573899d2 100644 --- a/homeassistant/components/hue/v1/device_trigger.py +++ b/homeassistant/components/hue/v1/device_trigger.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN if TYPE_CHECKING: - from ..bridge import HueBridge + from ..bridge import HueBridge, HueConfigEntry TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} @@ -111,8 +111,9 @@ REMOTES: dict[str, dict[tuple[str, str], dict[str, int]]] = { def _get_hue_event_from_device_id(hass, device_id): """Resolve hue event from device id.""" - for bridge in hass.data.get(DOMAIN, {}).values(): - for hue_event in bridge.sensor_manager.current_events.values(): + entries: list[HueConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + for entry in entries: + for hue_event in entry.runtime_data.sensor_manager.current_events.values(): if device_id == hue_event.device_registry_id: return hue_event diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index 33b99a7895b..b7251382296 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -28,10 +28,11 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -39,13 +40,13 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import color as color_util -from ..bridge import HueBridge +from ..bridge import HueConfigEntry from ..const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, - DOMAIN as HUE_DOMAIN, + DOMAIN, GROUP_TYPE_ENTERTAINMENT, GROUP_TYPE_LIGHT_GROUP, GROUP_TYPE_LIGHT_SOURCE, @@ -139,11 +140,15 @@ def create_light(item_class, coordinator, bridge, is_group, rooms, api, item_id) ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Set up the Hue lights from a config entry.""" - bridge: HueBridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api_version = tuple(int(v) for v in bridge.api.config.apiversion.split(".")) - rooms = {} + rooms: dict[str, str] = {} allow_groups = config_entry.options.get( CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS @@ -518,7 +523,7 @@ class HueLight(CoordinatorEntity, LightEntity): suggested_area = self._rooms[self.light.id] return DeviceInfo( - identifiers={(HUE_DOMAIN, self.device_id)}, + identifiers={(DOMAIN, self.device_id)}, manufacturer=self.light.manufacturername, # productname added in Hue Bridge API 1.24 # (published 03/05/2018) @@ -526,7 +531,7 @@ class HueLight(CoordinatorEntity, LightEntity): name=self.name, sw_version=self.light.swversion, suggested_area=suggested_area, - via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), + via_device=(DOMAIN, self.bridge.api.config.bridgeid), ) async def async_turn_on(self, **kwargs): diff --git a/homeassistant/components/hue/v1/sensor.py b/homeassistant/components/hue/v1/sensor.py index 88d494ed44b..765808bdf18 100644 --- a/homeassistant/components/hue/v1/sensor.py +++ b/homeassistant/components/hue/v1/sensor.py @@ -13,8 +13,10 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..const import DOMAIN as HUE_DOMAIN +from ..bridge import HueConfigEntry from .sensor_base import SENSOR_CONFIG_MAP, GenericHueSensor, GenericZLLSensor LIGHT_LEVEL_NAME_FORMAT = "{} light level" @@ -22,9 +24,13 @@ REMOTE_NAME_FORMAT = "{} battery level" TEMPERATURE_NAME_FORMAT = "{} temperature" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Defer sensor setup to the shared sensor module.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if not bridge.sensor_manager: return diff --git a/homeassistant/components/hue/v1/sensor_device.py b/homeassistant/components/hue/v1/sensor_device.py index cb0a2721334..a18f2176f67 100644 --- a/homeassistant/components/hue/v1/sensor_device.py +++ b/homeassistant/components/hue/v1/sensor_device.py @@ -3,11 +3,7 @@ from homeassistant.helpers import entity from homeassistant.helpers.device_registry import DeviceInfo -from ..const import ( - CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_UNREACHABLE, - DOMAIN as HUE_DOMAIN, -) +from ..const import CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE, DOMAIN class GenericHueDevice(entity.Entity): # pylint: disable=hass-enforce-class-module @@ -55,10 +51,10 @@ class GenericHueDevice(entity.Entity): # pylint: disable=hass-enforce-class-mod Links individual entities together in the hass device registry. """ return DeviceInfo( - identifiers={(HUE_DOMAIN, self.device_id)}, + identifiers={(DOMAIN, self.device_id)}, manufacturer=self.primary_sensor.manufacturername, model=(self.primary_sensor.productname or self.primary_sensor.modelid), name=self.primary_sensor.name, sw_version=self.primary_sensor.swversion, - via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), + via_device=(DOMAIN, self.bridge.api.config.bridgeid), ) diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 6e4c7f98973..17584a0f5cb 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -27,13 +27,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..bridge import HueBridge -from ..const import DOMAIN +from ..bridge import HueConfigEntry from .entity import HueBaseEntity type SensorType = CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper @@ -48,11 +46,11 @@ type ControllerType = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue Sensors from Config Entry.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api @callback diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 2f9f195df97..4db9bc16ca8 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -22,14 +22,13 @@ from homeassistant.components.light import ( LightEntityDescription, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from ..bridge import HueBridge +from ..bridge import HueBridge, HueConfigEntry from ..const import DOMAIN from .entity import HueBaseEntity from .helpers import ( @@ -41,11 +40,11 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue groups on light platform.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api async def async_add_light(event_type: EventType, resource: GroupedLight) -> None: diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 8eb7ec8936e..d83cdaa8009 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -26,13 +26,12 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import color as color_util -from ..bridge import HueBridge +from ..bridge import HueBridge, HueConfigEntry from ..const import DOMAIN from .entity import HueBaseEntity from .helpers import ( @@ -51,11 +50,11 @@ DEPRECATED_EFFECT_NONE = "None" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue Light from Config Entry.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api controller: LightsController = api.lights make_light_entity = partial(HueLight, bridge, controller) diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index ae6e456a8b4..1eec4eaa6b9 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -25,13 +25,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..bridge import HueBridge -from ..const import DOMAIN +from ..bridge import HueBridge, HueConfigEntry from .entity import HueBaseEntity type SensorType = DevicePower | LightLevel | Temperature | ZigbeeConnectivity @@ -45,11 +43,11 @@ type ControllerType = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue Sensors from Config Entry.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api ctrl_base: SensorsController = api.sensors diff --git a/homeassistant/components/huisbaasje/__init__.py b/homeassistant/components/huisbaasje/__init__.py index f9703f67df5..7eca8141dc3 100644 --- a/homeassistant/components/huisbaasje/__init__.py +++ b/homeassistant/components/huisbaasje/__init__.py @@ -1,36 +1,21 @@ """The EnergyFlip integration.""" -import asyncio -from datetime import timedelta import logging -from typing import Any from energyflip import EnergyFlip, EnergyFlipException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - DATA_COORDINATOR, - DOMAIN, - FETCH_TIMEOUT, - POLLING_INTERVAL, - SENSOR_TYPE_RATE, - SENSOR_TYPE_THIS_DAY, - SENSOR_TYPE_THIS_MONTH, - SENSOR_TYPE_THIS_WEEK, - SENSOR_TYPE_THIS_YEAR, - SOURCE_TYPES, -) +from .const import FETCH_TIMEOUT, SOURCE_TYPES +from .coordinator import EnergyFlipConfigEntry, EnergyFlipUpdateCoordinator PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EnergyFlipConfigEntry) -> bool: """Set up EnergyFlip from a config entry.""" # Create the EnergyFlip client energyflip = EnergyFlip( @@ -47,23 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Authentication failed: %s", str(exception)) return False - async def async_update_data() -> dict[str, dict[str, Any]]: - return await async_update_energyflip(energyflip) - # Create a coordinator for polling updates - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="sensor", - update_method=async_update_data, - update_interval=timedelta(seconds=POLLING_INTERVAL), - ) + coordinator = EnergyFlipUpdateCoordinator(hass, entry, energyflip) await coordinator.async_config_entry_first_refresh() # Load the client in the data of home assistant - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_COORDINATOR: coordinator} + entry.runtime_data = coordinator # Offload the loading of entities to the platform await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -71,87 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EnergyFlipConfigEntry) -> bool: """Unload a config entry.""" # Forward the unloading of the entry to the platform - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - # If successful, unload the EnergyFlip client - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok - - -async def async_update_energyflip(energyflip: EnergyFlip) -> dict[str, dict[str, Any]]: - """Update the data by performing a request to EnergyFlip.""" - try: - # Note: TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with asyncio.timeout(FETCH_TIMEOUT): - if not energyflip.is_authenticated(): - _LOGGER.warning("EnergyFlip is unauthenticated. Reauthenticating") - await energyflip.authenticate() - - current_measurements = await energyflip.current_measurements() - - return { - source_type: { - SENSOR_TYPE_RATE: _get_measurement_rate( - current_measurements, source_type - ), - SENSOR_TYPE_THIS_DAY: _get_cumulative_value( - current_measurements, source_type, SENSOR_TYPE_THIS_DAY - ), - SENSOR_TYPE_THIS_WEEK: _get_cumulative_value( - current_measurements, source_type, SENSOR_TYPE_THIS_WEEK - ), - SENSOR_TYPE_THIS_MONTH: _get_cumulative_value( - current_measurements, source_type, SENSOR_TYPE_THIS_MONTH - ), - SENSOR_TYPE_THIS_YEAR: _get_cumulative_value( - current_measurements, source_type, SENSOR_TYPE_THIS_YEAR - ), - } - for source_type in SOURCE_TYPES - } - except EnergyFlipException as exception: - raise UpdateFailed(f"Error communicating with API: {exception}") from exception - - -def _get_cumulative_value( - current_measurements: dict, - source_type: str, - period_type: str, -): - """Get the cumulative energy consumption for a certain period. - - :param current_measurements: The result from the EnergyFlip client - :param source_type: The source of energy (electricity or gas) - :param period_type: The period for which cumulative value should be given. - """ - if source_type in current_measurements: - if ( - period_type in current_measurements[source_type] - and current_measurements[source_type][period_type] is not None - ): - return current_measurements[source_type][period_type]["value"] - else: - _LOGGER.error( - "Source type %s not present in %s", source_type, current_measurements - ) - return None - - -def _get_measurement_rate(current_measurements: dict, source_type: str): - if source_type in current_measurements: - if ( - "measurement" in current_measurements[source_type] - and current_measurements[source_type]["measurement"] is not None - ): - return current_measurements[source_type]["measurement"]["rate"] - else: - _LOGGER.error( - "Source type %s not present in %s", source_type, current_measurements - ) - return None + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/huisbaasje/const.py b/homeassistant/components/huisbaasje/const.py index 2738289343f..a2dc39cb565 100644 --- a/homeassistant/components/huisbaasje/const.py +++ b/homeassistant/components/huisbaasje/const.py @@ -9,8 +9,6 @@ from energyflip.const import ( SOURCE_TYPE_GAS, ) -DATA_COORDINATOR = "coordinator" - DOMAIN = "huisbaasje" """Interval in seconds between polls to EnergyFlip.""" diff --git a/homeassistant/components/huisbaasje/coordinator.py b/homeassistant/components/huisbaasje/coordinator.py new file mode 100644 index 00000000000..529f7916bc6 --- /dev/null +++ b/homeassistant/components/huisbaasje/coordinator.py @@ -0,0 +1,128 @@ +"""The EnergyFlip integration.""" + +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from energyflip import EnergyFlip, EnergyFlipException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + FETCH_TIMEOUT, + POLLING_INTERVAL, + SENSOR_TYPE_RATE, + SENSOR_TYPE_THIS_DAY, + SENSOR_TYPE_THIS_MONTH, + SENSOR_TYPE_THIS_WEEK, + SENSOR_TYPE_THIS_YEAR, + SOURCE_TYPES, +) + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + +type EnergyFlipConfigEntry = ConfigEntry[EnergyFlipUpdateCoordinator] + + +class EnergyFlipUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """EnergyFlip data update coordinator.""" + + config_entry: EnergyFlipConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: EnergyFlipConfigEntry, + energyflip: EnergyFlip, + ) -> None: + """Initialize the Huisbaasje data coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="sensor", + update_interval=timedelta(seconds=POLLING_INTERVAL), + ) + + self._energyflip = energyflip + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Update the data by performing a request to EnergyFlip.""" + try: + # Note: TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with asyncio.timeout(FETCH_TIMEOUT): + if not self._energyflip.is_authenticated(): + _LOGGER.warning("EnergyFlip is unauthenticated. Reauthenticating") + await self._energyflip.authenticate() + + current_measurements = await self._energyflip.current_measurements() + + return { + source_type: { + SENSOR_TYPE_RATE: _get_measurement_rate( + current_measurements, source_type + ), + SENSOR_TYPE_THIS_DAY: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_DAY + ), + SENSOR_TYPE_THIS_WEEK: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_WEEK + ), + SENSOR_TYPE_THIS_MONTH: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_MONTH + ), + SENSOR_TYPE_THIS_YEAR: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_YEAR + ), + } + for source_type in SOURCE_TYPES + } + except EnergyFlipException as exception: + raise UpdateFailed( + f"Error communicating with API: {exception}" + ) from exception + + +def _get_cumulative_value( + current_measurements: dict, + source_type: str, + period_type: str, +): + """Get the cumulative energy consumption for a certain period. + + :param current_measurements: The result from the EnergyFlip client + :param source_type: The source of energy (electricity or gas) + :param period_type: The period for which cumulative value should be given. + """ + if source_type in current_measurements: + if ( + period_type in current_measurements[source_type] + and current_measurements[source_type][period_type] is not None + ): + return current_measurements[source_type][period_type]["value"] + else: + _LOGGER.error( + "Source type %s not present in %s", source_type, current_measurements + ) + return None + + +def _get_measurement_rate(current_measurements: dict, source_type: str): + if source_type in current_measurements: + if ( + "measurement" in current_measurements[source_type] + and current_measurements[source_type]["measurement"] is not None + ): + return current_measurements[source_type]["measurement"]["rate"] + else: + _LOGGER.error( + "Source type %s not present in %s", source_type, current_measurements + ) + return None diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 91c953b2182..d6049e58550 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import Any from energyflip.const import ( SOURCE_TYPE_ELECTRICITY, @@ -21,7 +20,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ID, UnitOfEnergy, @@ -31,13 +29,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - DATA_COORDINATOR, DOMAIN, SENSOR_TYPE_RATE, SENSOR_TYPE_THIS_DAY, @@ -45,6 +39,7 @@ from .const import ( SENSOR_TYPE_THIS_WEEK, SENSOR_TYPE_THIS_YEAR, ) +from .coordinator import EnergyFlipConfigEntry, EnergyFlipUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -218,13 +213,11 @@ SENSORS_INFO = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EnergyFlipConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]] = hass.data[DOMAIN][ - config_entry.entry_id - ][DATA_COORDINATOR] + coordinator = config_entry.runtime_data user_id = config_entry.data[CONF_ID] async_add_entities( @@ -233,9 +226,7 @@ async def async_setup_entry( ) -class EnergyFlipSensor( - CoordinatorEntity[DataUpdateCoordinator[dict[str, dict[str, Any]]]], SensorEntity -): +class EnergyFlipSensor(CoordinatorEntity[EnergyFlipUpdateCoordinator], SensorEntity): """Defines a EnergyFlip sensor.""" entity_description: EnergyFlipSensorEntityDescription @@ -243,7 +234,7 @@ class EnergyFlipSensor( def __init__( self, - coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + coordinator: EnergyFlipUpdateCoordinator, user_id: str, description: EnergyFlipSensorEntityDescription, ) -> None: diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py index 1e5b9fac990..82c78123bde 100644 --- a/homeassistant/components/husqvarna_automower/binary_sensor.py +++ b/homeassistant/components/husqvarna_automower/binary_sensor.py @@ -3,29 +3,18 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from typing import TYPE_CHECKING from aioautomower.model import MowerActivities, MowerAttributes -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity 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 . import AutomowerConfigEntry -from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity @@ -34,13 +23,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: - """Get list of related automations and scripts.""" - used_in = automations_with_entity(hass, entity_id) - used_in += scripts_with_entity(hass, entity_id) - return used_in - - @dataclass(frozen=True, kw_only=True) class AutomowerBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Automower binary sensor entity.""" @@ -59,12 +41,6 @@ MOWER_BINARY_SENSOR_TYPES: tuple[AutomowerBinarySensorEntityDescription, ...] = translation_key="leaving_dock", value_fn=lambda data: data.mower.activity == MowerActivities.LEAVING, ), - AutomowerBinarySensorEntityDescription( - key="returning_to_dock", - translation_key="returning_to_dock", - value_fn=lambda data: data.mower.activity == MowerActivities.GOING_HOME, - entity_registry_enabled_default=False, - ), ) @@ -107,39 +83,3 @@ class AutomowerBinarySensorEntity(AutomowerBaseEntity, BinarySensorEntity): def is_on(self) -> bool: """Return the state of the binary sensor.""" return self.entity_description.value_fn(self.mower_attributes) - - async def async_added_to_hass(self) -> None: - """Raise issue when entity is registered and was not disabled.""" - if TYPE_CHECKING: - assert self.unique_id - if not ( - entity_id := er.async_get(self.hass).async_get_entity_id( - BINARY_SENSOR_DOMAIN, DOMAIN, self.unique_id - ) - ): - return - if ( - self.enabled - and self.entity_description.key == "returning_to_dock" - and entity_used_in(self.hass, entity_id) - ): - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_entity_{self.entity_description.key}", - breaks_in_ha_version="2025.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders={ - "entity_name": str(self.name), - "entity": entity_id, - }, - ) - else: - async_delete_issue( - self.hass, - DOMAIN, - f"deprecated_task_entity_{self.entity_description.key}", - ) - await super().async_added_to_hass() diff --git a/homeassistant/components/husqvarna_automower/quality_scale.yaml b/homeassistant/components/husqvarna_automower/quality_scale.yaml index 2fa41c02a4c..d0435c51eee 100644 --- a/homeassistant/components/husqvarna_automower/quality_scale.yaml +++ b/homeassistant/components/husqvarna_automower/quality_scale.yaml @@ -67,7 +67,9 @@ rules: reconfiguration-flow: status: exempt comment: no configuration possible - repair-issues: done + repair-issues: + status: exempt + comment: no issues available stale-devices: done # Platinum diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py index 9124a0705e1..1dde9e16295 100644 --- a/homeassistant/components/husqvarna_automower/select.py +++ b/homeassistant/components/husqvarna_automower/select.py @@ -19,10 +19,10 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 HEADLIGHT_MODES: list = [ - HeadlightModes.ALWAYS_OFF.lower(), - HeadlightModes.ALWAYS_ON.lower(), - HeadlightModes.EVENING_AND_NIGHT.lower(), - HeadlightModes.EVENING_ONLY.lower(), + HeadlightModes.ALWAYS_OFF, + HeadlightModes.ALWAYS_ON, + HeadlightModes.EVENING_AND_NIGHT, + HeadlightModes.EVENING_ONLY, ] @@ -65,13 +65,11 @@ class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity): @property def current_option(self) -> str: """Return the current option for the entity.""" - return cast( - HeadlightModes, self.mower_attributes.settings.headlight.mode - ).lower() + return cast(HeadlightModes, self.mower_attributes.settings.headlight.mode) @handle_sending_exception() async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.coordinator.api.commands.set_headlight_mode( - self.mower_id, cast(HeadlightModes, option.upper()) + self.mower_id, HeadlightModes(option) ) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 015d322c481..5b815e79263 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -39,9 +39,6 @@ "binary_sensor": { "leaving_dock": { "name": "Leaving dock" - }, - "returning_to_dock": { - "name": "Returning to dock" } }, "button": { @@ -323,12 +320,6 @@ } } }, - "issues": { - "deprecated_entity": { - "title": "The Husqvarna Automower {entity_name} sensor is deprecated", - "description": "The Husqvarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added lawn mower entity.\nWhen you are done migrating you can disable `{entity}`." - } - }, "services": { "override_schedule": { "name": "Override schedule", diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 7566b5c9d32..6eb618cbb04 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "iot_class": "local_polling", - "requirements": ["automower-ble==0.2.0"] + "requirements": ["automower-ble==0.2.1"] } diff --git a/homeassistant/components/hvv_departures/__init__.py b/homeassistant/components/hvv_departures/__init__.py index 1104359111c..cfe76591688 100644 --- a/homeassistant/components/hvv_departures/__init__.py +++ b/homeassistant/components/hvv_departures/__init__.py @@ -1,17 +1,15 @@ """The HVV integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DOMAIN -from .hub import GTIHub +from .hub import GTIHub, HVVConfigEntry PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HVVConfigEntry) -> bool: """Set up HVV from a config entry.""" hub = GTIHub( @@ -21,14 +19,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: aiohttp_client.async_get_clientsession(hass), ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = hub + entry.runtime_data = hub 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: HVVConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 622a8436e04..18598dd4c94 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -25,17 +24,18 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ATTRIBUTION, CONF_STATION, DOMAIN, MANUFACTURER +from .hub import HVVConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HVVConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary_sensor platform.""" - hub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data station_name = entry.data[CONF_STATION]["name"] station = entry.data[CONF_STATION] diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index d76ccef7cab..63d457bf302 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -9,18 +9,13 @@ from pygti.auth import GTI_DEFAULT_HOST from pygti.exceptions import CannotConnect, InvalidAuth import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_OFFSET, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import CONF_FILTER, CONF_REAL_TIME, CONF_STATION, DOMAIN -from .hub import GTIHub +from .hub import GTIHub, HVVConfigEntry _LOGGER = logging.getLogger(__name__) @@ -137,7 +132,7 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: HVVConfigEntry, ) -> OptionsFlowHandler: """Get options flow.""" return OptionsFlowHandler() @@ -146,6 +141,8 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlow): """Options flow handler.""" + config_entry: HVVConfigEntry + def __init__(self) -> None: """Initialize HVV Departures options flow.""" self.departure_filters: dict[str, Any] = {} @@ -157,7 +154,7 @@ class OptionsFlowHandler(OptionsFlow): errors = {} if not self.departure_filters: departure_list = {} - hub: GTIHub = self.hass.data[DOMAIN][self.config_entry.entry_id] + hub = self.config_entry.runtime_data try: departure_list = await hub.gti.departureList( diff --git a/homeassistant/components/hvv_departures/hub.py b/homeassistant/components/hvv_departures/hub.py index 7cffbed345c..31523b72ba1 100644 --- a/homeassistant/components/hvv_departures/hub.py +++ b/homeassistant/components/hvv_departures/hub.py @@ -2,6 +2,10 @@ from pygti.gti import GTI, Auth +from homeassistant.config_entries import ConfigEntry + +type HVVConfigEntry = ConfigEntry[GTIHub] + class GTIHub: """GTI Hub.""" diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 667893db8f2..1b10451f22d 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -8,7 +8,6 @@ from aiohttp import ClientConnectorError from pygti.exceptions import InvalidAuth from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID, CONF_OFFSET from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -18,6 +17,7 @@ from homeassistant.util import Throttle from homeassistant.util.dt import get_time_zone, utcnow from .const import ATTRIBUTION, CONF_REAL_TIME, CONF_STATION, DOMAIN, MANUFACTURER +from .hub import HVVConfigEntry MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) MAX_LIST = 20 @@ -41,11 +41,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HVVConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - hub = hass.data[DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data session = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ce4d7a8f8c2..d15df52bb71 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -2,13 +2,13 @@ from pydrawise import auth, hybrid -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import APP_ID, DOMAIN +from .const import APP_ID from .coordinator import ( + HydrawiseConfigEntry, HydrawiseMainDataUpdateCoordinator, HydrawiseUpdateCoordinators, HydrawiseWaterUseDataUpdateCoordinator, @@ -24,7 +24,9 @@ PLATFORMS: list[Platform] = [ _REQUIRED_AUTH_KEYS = (CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: HydrawiseConfigEntry +) -> bool: """Set up Hydrawise from a config entry.""" if any(k not in config_entry.data for k in _REQUIRED_AUTH_KEYS): # If we are missing any required authentication keys, trigger a reauth flow. @@ -45,18 +47,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass, config_entry, hydrawise, main_coordinator ) await water_use_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ( - HydrawiseUpdateCoordinators( - main=main_coordinator, - water_use=water_use_coordinator, - ) + config_entry.runtime_data = HydrawiseUpdateCoordinators( + main=main_coordinator, + water_use=water_use_coordinator, ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HydrawiseConfigEntry) -> 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/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index b2862930933..45537a2cc73 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -14,14 +14,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType -from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND -from .coordinator import HydrawiseUpdateCoordinators +from .const import SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND +from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity @@ -77,11 +76,11 @@ SCHEMA_SUSPEND: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise binary_sensor platform.""" - coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data entities: list[HydrawiseBinarySensor] = [] for controller in coordinators.main.data.controllers.values(): entities.extend( diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 35d816b341b..15d286801f9 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -14,6 +14,8 @@ from homeassistant.util.dt import now from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL +type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators] + @dataclass class HydrawiseData: @@ -40,7 +42,7 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """Base class for Hydrawise Data Update Coordinators.""" api: HydrawiseBase - config_entry: ConfigEntry + config_entry: HydrawiseConfigEntry class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): @@ -52,7 +54,10 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): """ def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: HydrawiseBase + self, + hass: HomeAssistant, + config_entry: HydrawiseConfigEntry, + api: HydrawiseBase, ) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" super().__init__( @@ -92,7 +97,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, api: HydrawiseBase, main_coordinator: HydrawiseMainDataUpdateCoordinator, ) -> None: diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 0c355c34a71..03b9dc68a79 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.3.0"] + "requirements": ["pydrawise==2025.6.0"] } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 60bc1d7dc63..ce0bc5a0997 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -14,14 +14,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import HydrawiseUpdateCoordinators +from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity @@ -130,11 +128,11 @@ FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise sensor platform.""" - coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data entities: list[HydrawiseSensor] = [] for controller in coordinators.main.data.controllers.values(): entities.extend( diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index bc6b31e6d2e..7a77f27265b 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -14,13 +14,12 @@ 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 homeassistant.util import dt as dt_util -from .const import DEFAULT_WATERING_TIME, DOMAIN -from .coordinator import HydrawiseUpdateCoordinators +from .const import DEFAULT_WATERING_TIME +from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity @@ -62,11 +61,11 @@ SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise switch platform.""" - coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data async_add_entities( HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) for controller in coordinators.main.data.controllers.values() diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 13aff22ccbf..85a91c807b2 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -12,12 +12,10 @@ from homeassistant.components.valve import ( ValveEntityDescription, ValveEntityFeature, ) -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 HydrawiseUpdateCoordinators +from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity VALVE_TYPES: tuple[ValveEntityDescription, ...] = ( @@ -30,11 +28,11 @@ VALVE_TYPES: tuple[ValveEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise valve platform.""" - coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data async_add_entities( HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id) for controller in coordinators.main.data.controllers.values() diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index 2484a46f906..1604b37b967 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -6,18 +6,16 @@ import asyncio from pyialarm import IAlarm -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import IAlarmDataUpdateCoordinator +from .coordinator import IAlarmConfigEntry, IAlarmDataUpdateCoordinator PLATFORMS = [Platform.ALARM_CONTROL_PANEL] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IAlarmConfigEntry) -> bool: """Set up iAlarm config.""" host: str = entry.data[CONF_HOST] port: int = entry.data[CONF_PORT] @@ -32,20 +30,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = IAlarmDataUpdateCoordinator(hass, entry, ialarm, mac) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IAlarmConfigEntry) -> bool: """Unload iAlarm config.""" - 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/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index e203f892c35..b2de9b3fefc 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -7,26 +7,22 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import IAlarmDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import IAlarmConfigEntry, IAlarmDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IAlarmConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a iAlarm alarm control panel based on a config entry.""" - coordinator: IAlarmDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] - async_add_entities([IAlarmPanel(coordinator)], False) + async_add_entities([IAlarmPanel(entry.runtime_data)], False) class IAlarmPanel( diff --git a/homeassistant/components/ialarm/const.py b/homeassistant/components/ialarm/const.py index 1b8074c34f0..01ce47e002a 100644 --- a/homeassistant/components/ialarm/const.py +++ b/homeassistant/components/ialarm/const.py @@ -4,8 +4,6 @@ from pyialarm import IAlarm from homeassistant.components.alarm_control_panel import AlarmControlPanelState -DATA_COORDINATOR = "ialarm" - DEFAULT_PORT = 18034 DOMAIN = "ialarm" diff --git a/homeassistant/components/ialarm/coordinator.py b/homeassistant/components/ialarm/coordinator.py index 61e87c36796..546e0b6b714 100644 --- a/homeassistant/components/ialarm/coordinator.py +++ b/homeassistant/components/ialarm/coordinator.py @@ -19,14 +19,20 @@ from .const import DOMAIN, IALARM_TO_HASS _LOGGER = logging.getLogger(__name__) +type IAlarmConfigEntry = ConfigEntry[IAlarmDataUpdateCoordinator] + class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching iAlarm data.""" - config_entry: ConfigEntry + config_entry: IAlarmConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, ialarm: IAlarm, mac: str + self, + hass: HomeAssistant, + config_entry: IAlarmConfigEntry, + ialarm: IAlarm, + mac: str, ) -> None: """Initialize global iAlarm data updater.""" self.ialarm = ialarm diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 26bffc4e982..68a8a093c09 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass from datetime import datetime from functools import wraps import logging @@ -19,11 +20,6 @@ from iaqualink.device import ( ) from iaqualink.exception import AqualinkServiceException -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant @@ -48,21 +44,27 @@ PLATFORMS = [ Platform.SWITCH, ] +type AqualinkConfigEntry = ConfigEntry[AqualinkRuntimeData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class AqualinkRuntimeData: + """Runtime data for Aqualink.""" + + client: AqualinkClient + # These will contain the initialized devices + binary_sensors: list[AqualinkBinarySensor] + lights: list[AqualinkLight] + sensors: list[AqualinkSensor] + switches: list[AqualinkSwitch] + thermostats: list[AqualinkThermostat] + + +async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> bool: """Set up Aqualink from a config entry.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] - hass.data.setdefault(DOMAIN, {}) - - # These will contain the initialized devices - binary_sensors = hass.data[DOMAIN][BINARY_SENSOR_DOMAIN] = [] - climates = hass.data[DOMAIN][CLIMATE_DOMAIN] = [] - lights = hass.data[DOMAIN][LIGHT_DOMAIN] = [] - sensors = hass.data[DOMAIN][SENSOR_DOMAIN] = [] - switches = hass.data[DOMAIN][SWITCH_DOMAIN] = [] - aqualink = AqualinkClient(username, password, httpx_client=get_async_client(hass)) try: await aqualink.login() @@ -90,6 +92,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await aqualink.close() return False + runtime_data = AqualinkRuntimeData( + aqualink, binary_sensors=[], lights=[], sensors=[], switches=[], thermostats=[] + ) for system in systems: try: devices = await system.get_devices() @@ -101,36 +106,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for dev in devices.values(): if isinstance(dev, AqualinkThermostat): - climates += [dev] + runtime_data.thermostats += [dev] elif isinstance(dev, AqualinkLight): - lights += [dev] + runtime_data.lights += [dev] elif isinstance(dev, AqualinkSwitch): - switches += [dev] + runtime_data.switches += [dev] elif isinstance(dev, AqualinkBinarySensor): - binary_sensors += [dev] + runtime_data.binary_sensors += [dev] elif isinstance(dev, AqualinkSensor): - sensors += [dev] + runtime_data.sensors += [dev] - platforms = [] - if binary_sensors: - _LOGGER.debug("Got %s binary sensors: %s", len(binary_sensors), binary_sensors) - platforms.append(Platform.BINARY_SENSOR) - if climates: - _LOGGER.debug("Got %s climates: %s", len(climates), climates) - platforms.append(Platform.CLIMATE) - if lights: - _LOGGER.debug("Got %s lights: %s", len(lights), lights) - platforms.append(Platform.LIGHT) - if sensors: - _LOGGER.debug("Got %s sensors: %s", len(sensors), sensors) - platforms.append(Platform.SENSOR) - if switches: - _LOGGER.debug("Got %s switches: %s", len(switches), switches) - platforms.append(Platform.SWITCH) + _LOGGER.debug( + "Got %s binary sensors: %s", + len(runtime_data.binary_sensors), + runtime_data.binary_sensors, + ) + _LOGGER.debug("Got %s lights: %s", len(runtime_data.lights), runtime_data.lights) + _LOGGER.debug("Got %s sensors: %s", len(runtime_data.sensors), runtime_data.sensors) + _LOGGER.debug( + "Got %s switches: %s", len(runtime_data.switches), runtime_data.switches + ) + _LOGGER.debug( + "Got %s thermostats: %s", + len(runtime_data.thermostats), + runtime_data.thermostats, + ) - hass.data[DOMAIN]["client"] = aqualink + entry.runtime_data = runtime_data - await hass.config_entries.async_forward_entry_setups(entry, platforms) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def _async_systems_update(_: datetime) -> None: """Refresh internal state for all systems.""" @@ -161,18 +165,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> bool: """Unload a config entry.""" - aqualink = hass.data[DOMAIN]["client"] - await aqualink.close() - - platforms_to_unload = [ - platform for platform in PLATFORMS if platform in hass.data[DOMAIN] - ] - - del hass.data[DOMAIN] - - return await hass.config_entries.async_unload_platforms(entry, platforms_to_unload) + await entry.runtime_data.client.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def refresh_system[_AqualinkEntityT: AqualinkEntity, **_P]( diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 8fe9d77fbe8..3c260c7ef03 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -5,15 +5,13 @@ from __future__ import annotations from iaqualink.device import AqualinkBinarySensor from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -21,20 +19,22 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered binary sensors.""" async_add_entities( ( HassAqualinkBinarySensor(dev) - for dev in hass.data[AQUALINK_DOMAIN][BINARY_SENSOR_DOMAIN] + for dev in config_entry.runtime_data.binary_sensors ), True, ) -class HassAqualinkBinarySensor(AqualinkEntity, BinarySensorEntity): +class HassAqualinkBinarySensor( + AqualinkEntity[AqualinkBinarySensor], BinarySensorEntity +): """Representation of a binary sensor.""" def __init__(self, dev: AqualinkBinarySensor) -> None: diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index d30700898c8..36aec12976a 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -9,19 +9,16 @@ from iaqualink.device import AqualinkThermostat from iaqualink.systems.iaqua.device import AqualinkState from homeassistant.components.climate import ( - DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityFeature, HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry, refresh_system from .entity import AqualinkEntity from .utils import await_or_reraise @@ -32,20 +29,17 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered switches.""" async_add_entities( - ( - HassAqualinkThermostat(dev) - for dev in hass.data[AQUALINK_DOMAIN][CLIMATE_DOMAIN] - ), + (HassAqualinkThermostat(dev) for dev in config_entry.runtime_data.thermostats), True, ) -class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): +class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity): """Representation of a thermostat.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py index d0176ed8bfe..0b3751e5fbc 100644 --- a/homeassistant/components/iaqualink/entity.py +++ b/homeassistant/components/iaqualink/entity.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN -class AqualinkEntity(Entity): +class AqualinkEntity[AqualinkDeviceT: AqualinkDevice](Entity): """Abstract class for all Aqualink platforms. Entity state is updated via the interval timer within the integration. @@ -23,7 +23,7 @@ class AqualinkEntity(Entity): _attr_should_poll = False - def __init__(self, dev: AqualinkDevice) -> None: + def __init__(self, dev: AqualinkDeviceT) -> None: """Initialize the entity.""" self.dev = dev self._attr_unique_id = f"{dev.system.serial}_{dev.name}" diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index e515c482158..55b14065cef 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -9,17 +9,14 @@ from iaqualink.device import AqualinkLight from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, - DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry, refresh_system from .entity import AqualinkEntity from .utils import await_or_reraise @@ -28,17 +25,17 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered lights.""" async_add_entities( - (HassAqualinkLight(dev) for dev in hass.data[AQUALINK_DOMAIN][LIGHT_DOMAIN]), + (HassAqualinkLight(dev) for dev in config_entry.runtime_data.lights), True, ) -class HassAqualinkLight(AqualinkEntity, LightEntity): +class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity): """Representation of a light.""" def __init__(self, dev: AqualinkLight) -> None: diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index 1b453f28d8f..baeca799bc3 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -4,17 +4,12 @@ from __future__ import annotations from iaqualink.device import AqualinkSensor -from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -22,17 +17,17 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered sensors.""" async_add_entities( - (HassAqualinkSensor(dev) for dev in hass.data[AQUALINK_DOMAIN][SENSOR_DOMAIN]), + (HassAqualinkSensor(dev) for dev in config_entry.runtime_data.sensors), True, ) -class HassAqualinkSensor(AqualinkEntity, SensorEntity): +class HassAqualinkSensor(AqualinkEntity[AqualinkSensor], SensorEntity): """Representation of a sensor.""" def __init__(self, dev: AqualinkSensor) -> None: diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index e746cbb4f4b..851554a1972 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -6,13 +6,11 @@ from typing import Any from iaqualink.device import AqualinkSwitch -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry, refresh_system from .entity import AqualinkEntity from .utils import await_or_reraise @@ -21,17 +19,17 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered switches.""" async_add_entities( - (HassAqualinkSwitch(dev) for dev in hass.data[AQUALINK_DOMAIN][SWITCH_DOMAIN]), + (HassAqualinkSwitch(dev) for dev in config_entry.runtime_data.switches), True, ) -class HassAqualinkSwitch(AqualinkEntity, SwitchEntity): +class HassAqualinkSwitch(AqualinkEntity[AqualinkSwitch], SwitchEntity): """Representation of a switch.""" def __init__(self, dev: AqualinkSwitch) -> None: diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 4ed66be6a4b..13551ebece5 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -4,77 +4,25 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store -from homeassistant.util import slugify -from .account import IcloudAccount +from .account import IcloudAccount, IcloudConfigEntry from .const import ( CONF_GPS_ACCURACY_THRESHOLD, CONF_MAX_INTERVAL, CONF_WITH_FAMILY, - DOMAIN, PLATFORMS, STORAGE_KEY, STORAGE_VERSION, ) - -ATTRIBUTION = "Data provided by Apple iCloud" - -# entity attributes -ATTR_ACCOUNT_FETCH_INTERVAL = "account_fetch_interval" -ATTR_BATTERY = "battery" -ATTR_BATTERY_STATUS = "battery_status" -ATTR_DEVICE_NAME = "device_name" -ATTR_DEVICE_STATUS = "device_status" -ATTR_LOW_POWER_MODE = "low_power_mode" -ATTR_OWNER_NAME = "owner_fullname" - -# services -SERVICE_ICLOUD_PLAY_SOUND = "play_sound" -SERVICE_ICLOUD_DISPLAY_MESSAGE = "display_message" -SERVICE_ICLOUD_LOST_DEVICE = "lost_device" -SERVICE_ICLOUD_UPDATE = "update" -ATTR_ACCOUNT = "account" -ATTR_LOST_DEVICE_MESSAGE = "message" -ATTR_LOST_DEVICE_NUMBER = "number" -ATTR_LOST_DEVICE_SOUND = "sound" - -SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ACCOUNT): cv.string}) - -SERVICE_SCHEMA_PLAY_SOUND = vol.Schema( - {vol.Required(ATTR_ACCOUNT): cv.string, vol.Required(ATTR_DEVICE_NAME): cv.string} -) - -SERVICE_SCHEMA_DISPLAY_MESSAGE = vol.Schema( - { - vol.Required(ATTR_ACCOUNT): cv.string, - vol.Required(ATTR_DEVICE_NAME): cv.string, - vol.Required(ATTR_LOST_DEVICE_MESSAGE): cv.string, - vol.Optional(ATTR_LOST_DEVICE_SOUND): cv.boolean, - } -) - -SERVICE_SCHEMA_LOST_DEVICE = vol.Schema( - { - vol.Required(ATTR_ACCOUNT): cv.string, - vol.Required(ATTR_DEVICE_NAME): cv.string, - vol.Required(ATTR_LOST_DEVICE_NUMBER): cv.string, - vol.Required(ATTR_LOST_DEVICE_MESSAGE): cv.string, - } -) +from .services import register_services -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bool: """Set up an iCloud account from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] with_family = entry.data[CONF_WITH_FAMILY] @@ -99,93 +47,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.async_add_executor_job(account.setup) - hass.data[DOMAIN][entry.unique_id] = account + entry.runtime_data = account await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - def play_sound(service: ServiceCall) -> None: - """Play sound on the device.""" - account = service.data[ATTR_ACCOUNT] - device_name: str = service.data[ATTR_DEVICE_NAME] - device_name = slugify(device_name.replace(" ", "", 99)) - - for device in _get_account(account).get_devices_with_name(device_name): - device.play_sound() - - def display_message(service: ServiceCall) -> None: - """Display a message on the device.""" - account = service.data[ATTR_ACCOUNT] - device_name: str = service.data[ATTR_DEVICE_NAME] - device_name = slugify(device_name.replace(" ", "", 99)) - message = service.data.get(ATTR_LOST_DEVICE_MESSAGE) - sound = service.data.get(ATTR_LOST_DEVICE_SOUND, False) - - for device in _get_account(account).get_devices_with_name(device_name): - device.display_message(message, sound) - - def lost_device(service: ServiceCall) -> None: - """Make the device in lost state.""" - account = service.data[ATTR_ACCOUNT] - device_name: str = service.data[ATTR_DEVICE_NAME] - device_name = slugify(device_name.replace(" ", "", 99)) - number = service.data.get(ATTR_LOST_DEVICE_NUMBER) - message = service.data.get(ATTR_LOST_DEVICE_MESSAGE) - - for device in _get_account(account).get_devices_with_name(device_name): - device.lost_device(number, message) - - def update_account(service: ServiceCall) -> None: - """Call the update function of an iCloud account.""" - if (account := service.data.get(ATTR_ACCOUNT)) is None: - for account in hass.data[DOMAIN].values(): - account.keep_alive() - else: - _get_account(account).keep_alive() - - def _get_account(account_identifier: str) -> IcloudAccount: - if account_identifier is None: - return None - - icloud_account: IcloudAccount | None = hass.data[DOMAIN].get(account_identifier) - if icloud_account is None: - for account in hass.data[DOMAIN].values(): - if account.username == account_identifier: - icloud_account = account - - if icloud_account is None: - raise ValueError( - f"No iCloud account with username or name {account_identifier}" - ) - return icloud_account - - hass.services.async_register( - DOMAIN, SERVICE_ICLOUD_PLAY_SOUND, play_sound, schema=SERVICE_SCHEMA_PLAY_SOUND - ) - - hass.services.async_register( - DOMAIN, - SERVICE_ICLOUD_DISPLAY_MESSAGE, - display_message, - schema=SERVICE_SCHEMA_DISPLAY_MESSAGE, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_ICLOUD_LOST_DEVICE, - lost_device, - schema=SERVICE_SCHEMA_LOST_DEVICE, - ) - - hass.services.async_register( - DOMAIN, SERVICE_ICLOUD_UPDATE, update_account, schema=SERVICE_SCHEMA - ) + register_services(hass) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.data[CONF_USERNAME]) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 9536cd9ee5c..3006193a1ff 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -29,6 +29,13 @@ from homeassistant.util.dt import utcnow from homeassistant.util.location import distance from .const import ( + ATTR_ACCOUNT_FETCH_INTERVAL, + ATTR_BATTERY, + ATTR_BATTERY_STATUS, + ATTR_DEVICE_NAME, + ATTR_DEVICE_STATUS, + ATTR_LOW_POWER_MODE, + ATTR_OWNER_NAME, DEVICE_BATTERY_LEVEL, DEVICE_BATTERY_STATUS, DEVICE_CLASS, @@ -49,27 +56,10 @@ from .const import ( DOMAIN, ) -# entity attributes -ATTR_ACCOUNT_FETCH_INTERVAL = "account_fetch_interval" -ATTR_BATTERY = "battery" -ATTR_BATTERY_STATUS = "battery_status" -ATTR_DEVICE_NAME = "device_name" -ATTR_DEVICE_STATUS = "device_status" -ATTR_LOW_POWER_MODE = "low_power_mode" -ATTR_OWNER_NAME = "owner_fullname" - -# services -SERVICE_ICLOUD_PLAY_SOUND = "play_sound" -SERVICE_ICLOUD_DISPLAY_MESSAGE = "display_message" -SERVICE_ICLOUD_LOST_DEVICE = "lost_device" -SERVICE_ICLOUD_UPDATE = "update" -ATTR_ACCOUNT = "account" -ATTR_LOST_DEVICE_MESSAGE = "message" -ATTR_LOST_DEVICE_NUMBER = "number" -ATTR_LOST_DEVICE_SOUND = "sound" - _LOGGER = logging.getLogger(__name__) +type IcloudConfigEntry = ConfigEntry[IcloudAccount] + class IcloudAccount: """Representation of an iCloud account.""" @@ -83,7 +73,7 @@ class IcloudAccount: with_family: bool, max_interval: int, gps_accuracy_threshold: int, - config_entry: ConfigEntry, + config_entry: IcloudConfigEntry, ) -> None: """Initialize an iCloud account.""" self.hass = hass diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py index b7ea2691ca4..72b1d496121 100644 --- a/homeassistant/components/icloud/const.py +++ b/homeassistant/components/icloud/const.py @@ -4,6 +4,8 @@ from homeassistant.const import Platform DOMAIN = "icloud" +ATTRIBUTION = "Data provided by Apple iCloud" + CONF_WITH_FAMILY = "with_family" CONF_MAX_INTERVAL = "max_interval" CONF_GPS_ACCURACY_THRESHOLD = "gps_accuracy_threshold" @@ -84,3 +86,17 @@ DEVICE_STATUS_CODES = { "203": "pending", "204": "unregistered", } + + +# entity / service attributes +ATTR_ACCOUNT = "account" +ATTR_ACCOUNT_FETCH_INTERVAL = "account_fetch_interval" +ATTR_BATTERY = "battery" +ATTR_BATTERY_STATUS = "battery_status" +ATTR_DEVICE_NAME = "device_name" +ATTR_DEVICE_STATUS = "device_status" +ATTR_LOW_POWER_MODE = "low_power_mode" +ATTR_LOST_DEVICE_MESSAGE = "message" +ATTR_LOST_DEVICE_NUMBER = "number" +ATTR_LOST_DEVICE_SOUND = "sound" +ATTR_OWNER_NAME = "owner_fullname" diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index ca194143852..2a4f6d81dc5 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -2,16 +2,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, 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 .account import IcloudAccount, IcloudDevice +from .account import IcloudAccount, IcloudConfigEntry, IcloudDevice from .const import ( DEVICE_LOCATION_HORIZONTAL_ACCURACY, DEVICE_LOCATION_LATITUDE, @@ -22,11 +21,11 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IcloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for iCloud component.""" - account: IcloudAccount = hass.data[DOMAIN][entry.unique_id] + account = entry.runtime_data tracked = set[str]() @callback @@ -70,18 +69,24 @@ class IcloudTrackerEntity(TrackerEntity): self._attr_unique_id = device.unique_id @property - def location_accuracy(self): + def location_accuracy(self) -> float: """Return the location accuracy of the device.""" + if TYPE_CHECKING: + assert self._device.location is not None return self._device.location[DEVICE_LOCATION_HORIZONTAL_ACCURACY] @property - def latitude(self): + def latitude(self) -> float: """Return latitude value of the device.""" + if TYPE_CHECKING: + assert self._device.location is not None return self._device.location[DEVICE_LOCATION_LATITUDE] @property - def longitude(self): + def longitude(self) -> float: """Return longitude value of the device.""" + if TYPE_CHECKING: + assert self._device.location is not None return self._device.location[DEVICE_LOCATION_LONGITUDE] @property diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index 533605b8c7b..11690a0da59 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -13,17 +12,17 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from .account import IcloudAccount, IcloudDevice +from .account import IcloudAccount, IcloudConfigEntry, IcloudDevice from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IcloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for iCloud component.""" - account: IcloudAccount = hass.data[DOMAIN][entry.unique_id] + account = entry.runtime_data tracked = set[str]() @callback diff --git a/homeassistant/components/icloud/services.py b/homeassistant/components/icloud/services.py new file mode 100644 index 00000000000..5897fcb06f7 --- /dev/null +++ b/homeassistant/components/icloud/services.py @@ -0,0 +1,141 @@ +"""The iCloud component.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.util import slugify + +from .account import IcloudAccount +from .const import ( + ATTR_ACCOUNT, + ATTR_DEVICE_NAME, + ATTR_LOST_DEVICE_MESSAGE, + ATTR_LOST_DEVICE_NUMBER, + ATTR_LOST_DEVICE_SOUND, + DOMAIN, +) + +# services +SERVICE_ICLOUD_PLAY_SOUND = "play_sound" +SERVICE_ICLOUD_DISPLAY_MESSAGE = "display_message" +SERVICE_ICLOUD_LOST_DEVICE = "lost_device" +SERVICE_ICLOUD_UPDATE = "update" + +SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ACCOUNT): cv.string}) + +SERVICE_SCHEMA_PLAY_SOUND = vol.Schema( + {vol.Required(ATTR_ACCOUNT): cv.string, vol.Required(ATTR_DEVICE_NAME): cv.string} +) + +SERVICE_SCHEMA_DISPLAY_MESSAGE = vol.Schema( + { + vol.Required(ATTR_ACCOUNT): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_LOST_DEVICE_MESSAGE): cv.string, + vol.Optional(ATTR_LOST_DEVICE_SOUND): cv.boolean, + } +) + +SERVICE_SCHEMA_LOST_DEVICE = vol.Schema( + { + vol.Required(ATTR_ACCOUNT): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_LOST_DEVICE_NUMBER): cv.string, + vol.Required(ATTR_LOST_DEVICE_MESSAGE): cv.string, + } +) + + +def play_sound(service: ServiceCall) -> None: + """Play sound on the device.""" + account = service.data[ATTR_ACCOUNT] + device_name: str = service.data[ATTR_DEVICE_NAME] + device_name = slugify(device_name.replace(" ", "", 99)) + + for device in _get_account(service.hass, account).get_devices_with_name( + device_name + ): + device.play_sound() + + +def display_message(service: ServiceCall) -> None: + """Display a message on the device.""" + account = service.data[ATTR_ACCOUNT] + device_name: str = service.data[ATTR_DEVICE_NAME] + device_name = slugify(device_name.replace(" ", "", 99)) + message = service.data.get(ATTR_LOST_DEVICE_MESSAGE) + sound = service.data.get(ATTR_LOST_DEVICE_SOUND, False) + + for device in _get_account(service.hass, account).get_devices_with_name( + device_name + ): + device.display_message(message, sound) + + +def lost_device(service: ServiceCall) -> None: + """Make the device in lost state.""" + account = service.data[ATTR_ACCOUNT] + device_name: str = service.data[ATTR_DEVICE_NAME] + device_name = slugify(device_name.replace(" ", "", 99)) + number = service.data.get(ATTR_LOST_DEVICE_NUMBER) + message = service.data.get(ATTR_LOST_DEVICE_MESSAGE) + + for device in _get_account(service.hass, account).get_devices_with_name( + device_name + ): + device.lost_device(number, message) + + +def update_account(service: ServiceCall) -> None: + """Call the update function of an iCloud account.""" + if (account := service.data.get(ATTR_ACCOUNT)) is None: + for account in service.hass.data[DOMAIN].values(): + account.keep_alive() + else: + _get_account(service.hass, account).keep_alive() + + +def _get_account(hass: HomeAssistant, account_identifier: str) -> IcloudAccount: + if account_identifier is None: + return None + + icloud_account: IcloudAccount | None = hass.data[DOMAIN].get(account_identifier) + if icloud_account is None: + for account in hass.data[DOMAIN].values(): + if account.username == account_identifier: + icloud_account = account + + if icloud_account is None: + raise ValueError( + f"No iCloud account with username or name {account_identifier}" + ) + return icloud_account + + +def register_services(hass: HomeAssistant) -> None: + """Set up an iCloud account from a config entry.""" + + hass.services.async_register( + DOMAIN, SERVICE_ICLOUD_PLAY_SOUND, play_sound, schema=SERVICE_SCHEMA_PLAY_SOUND + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ICLOUD_DISPLAY_MESSAGE, + display_message, + schema=SERVICE_SCHEMA_DISPLAY_MESSAGE, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ICLOUD_LOST_DEVICE, + lost_device, + schema=SERVICE_SCHEMA_LOST_DEVICE, + ) + + hass.services.async_register( + DOMAIN, SERVICE_ICLOUD_UPDATE, update_account, schema=SERVICE_SCHEMA + ) diff --git a/homeassistant/components/igloohome/manifest.json b/homeassistant/components/igloohome/manifest.json index 35c58479d75..7bfb8f690c7 100644 --- a/homeassistant/components/igloohome/manifest.json +++ b/homeassistant/components/igloohome/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/igloohome", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["igloohome-api==0.1.0"] + "requirements": ["igloohome-api==0.1.1"] } diff --git a/homeassistant/components/imeon_inverter/const.py b/homeassistant/components/imeon_inverter/const.py index c71a8c72d11..fd08955c038 100644 --- a/homeassistant/components/imeon_inverter/const.py +++ b/homeassistant/components/imeon_inverter/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform DOMAIN = "imeon_inverter" -TIMEOUT = 20 +TIMEOUT = 30 PLATFORMS = [ Platform.SENSOR, ] diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py index b7a01c3cf17..a2f6ded5ab3 100644 --- a/homeassistant/components/imeon_inverter/sensor.py +++ b/homeassistant/components/imeon_inverter/sensor.py @@ -69,7 +69,7 @@ ENTITY_DESCRIPTIONS = ( translation_key="battery_stored", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY_STORAGE, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, ), # Grid SensorEntityDescription( diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py new file mode 100644 index 00000000000..18782ec6fd3 --- /dev/null +++ b/homeassistant/components/immich/__init__.py @@ -0,0 +1,56 @@ +"""The Immich integration.""" + +from __future__ import annotations + +from aioimmich import Immich +from aioimmich.const import CONNECT_ERRORS +from aioimmich.exceptions import ImmichUnauthorizedError + +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + 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 .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: + """Set up Immich from a config entry.""" + + session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) + immich = Immich( + session, + entry.data[CONF_API_KEY], + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_SSL], + ) + + try: + user_info = await immich.users.async_get_my_user() + except ImmichUnauthorizedError as err: + raise ConfigEntryAuthFailed from err + except CONNECT_ERRORS as err: + raise ConfigEntryNotReady from err + + coordinator = ImmichDataUpdateCoordinator(hass, entry, immich, user_info.is_admin) + 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: ImmichConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/immich/config_flow.py b/homeassistant/components/immich/config_flow.py new file mode 100644 index 00000000000..69fae3ff1eb --- /dev/null +++ b/homeassistant/components/immich/config_flow.py @@ -0,0 +1,174 @@ +"""Config flow for the Immich integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from aioimmich import Immich +from aioimmich.const import CONNECT_ERRORS +from aioimmich.exceptions import ImmichUnauthorizedError +from aioimmich.users.models import ImmichUser +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DEFAULT_VERIFY_SSL, DOMAIN + + +class InvalidUrl(HomeAssistantError): + """Error to indicate invalid URL.""" + + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Required(CONF_API_KEY): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) + + +def _parse_url(url: str) -> tuple[str, int, bool]: + """Parse the URL and return host, port, and ssl.""" + parsed_url = URL(url) + if ( + (host := parsed_url.host) is None + or (port := parsed_url.port) is None + or (scheme := parsed_url.scheme) is None + ): + raise InvalidUrl + return host, port, scheme == "https" + + +async def check_user_info( + hass: HomeAssistant, host: str, port: int, ssl: bool, verify_ssl: bool, api_key: str +) -> ImmichUser: + """Test connection and fetch own user info.""" + session = async_get_clientsession(hass, verify_ssl) + immich = Immich(session, api_key, host, port, ssl) + return await immich.users.async_get_my_user() + + +class ImmichConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Immich.""" + + VERSION = 1 + + _name: str + _current_data: Mapping[str, Any] + + 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: + try: + (host, port, ssl) = _parse_url(user_input[CONF_URL]) + except InvalidUrl: + errors[CONF_URL] = "invalid_url" + else: + try: + my_user_info = await check_user_info( + self.hass, + host, + port, + ssl, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + except ImmichUnauthorizedError: + errors["base"] = "invalid_auth" + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(my_user_info.user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=my_user_info.name, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: ssl, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + + 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: + """Trigger a reauthentication flow.""" + self._current_data = entry_data + self._name = entry_data[CONF_HOST] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthorization flow.""" + errors = {} + + if user_input is not None: + try: + my_user_info = await check_user_info( + self.hass, + self._current_data[CONF_HOST], + self._current_data[CONF_PORT], + self._current_data[CONF_SSL], + self._current_data[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + except ImmichUnauthorizedError: + errors["base"] = "invalid_auth" + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(my_user_info.user_id) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + description_placeholders={"name": self._name}, + errors=errors, + ) diff --git a/homeassistant/components/immich/const.py b/homeassistant/components/immich/const.py new file mode 100644 index 00000000000..47180967a67 --- /dev/null +++ b/homeassistant/components/immich/const.py @@ -0,0 +1,7 @@ +"""Constants for the Immich integration.""" + +DOMAIN = "immich" + +DEFAULT_PORT = 2283 +DEFAULT_USE_SSL = False +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/immich/coordinator.py b/homeassistant/components/immich/coordinator.py new file mode 100644 index 00000000000..2e89b0dae29 --- /dev/null +++ b/homeassistant/components/immich/coordinator.py @@ -0,0 +1,79 @@ +"""Coordinator for the Immich integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from aioimmich import Immich +from aioimmich.const import CONNECT_ERRORS +from aioimmich.exceptions import ImmichUnauthorizedError +from aioimmich.server.models import ( + ImmichServerAbout, + ImmichServerStatistics, + ImmichServerStorage, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class ImmichData: + """Data class for storing data from the API.""" + + server_about: ImmichServerAbout + server_storage: ImmichServerStorage + server_usage: ImmichServerStatistics | None + + +type ImmichConfigEntry = ConfigEntry[ImmichDataUpdateCoordinator] + + +class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]): + """Class to manage fetching IMGW-PIB data API.""" + + config_entry: ImmichConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, api: Immich, is_admin: bool + ) -> None: + """Initialize the data update coordinator.""" + self.api = api + self.is_admin = is_admin + self.configuration_url = ( + f"{'https' if entry.data[CONF_SSL] else 'http'}://" + f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" + ) + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=60), + ) + + async def _async_update_data(self) -> ImmichData: + """Update data via internal method.""" + try: + server_about = await self.api.server.async_get_about_info() + server_storage = await self.api.server.async_get_storage_info() + server_usage = ( + await self.api.server.async_get_server_statistics() + if self.is_admin + else None + ) + except ImmichUnauthorizedError as err: + raise ConfigEntryAuthFailed from err + except CONNECT_ERRORS as err: + raise UpdateFailed from err + + return ImmichData(server_about, server_storage, server_usage) diff --git a/homeassistant/components/immich/diagnostics.py b/homeassistant/components/immich/diagnostics.py new file mode 100644 index 00000000000..c44e24d8202 --- /dev/null +++ b/homeassistant/components/immich/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for immich.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant + +from .coordinator import ImmichConfigEntry + +TO_REDACT = {CONF_API_KEY, CONF_HOST} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ImmichConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": asdict(coordinator.data), + } diff --git a/homeassistant/components/immich/entity.py b/homeassistant/components/immich/entity.py new file mode 100644 index 00000000000..64ca11cca37 --- /dev/null +++ b/homeassistant/components/immich/entity.py @@ -0,0 +1,28 @@ +"""Base entity for the Immich integration.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ImmichDataUpdateCoordinator + + +class ImmichEntity(CoordinatorEntity[ImmichDataUpdateCoordinator]): + """Define immich base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ImmichDataUpdateCoordinator, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="Immich", + sw_version=coordinator.data.server_about.version, + entry_type=DeviceEntryType.SERVICE, + configuration_url=coordinator.configuration_url, + ) diff --git a/homeassistant/components/immich/icons.json b/homeassistant/components/immich/icons.json new file mode 100644 index 00000000000..15bac6370a6 --- /dev/null +++ b/homeassistant/components/immich/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "disk_usage": { + "default": "mdi:database" + }, + "photos_count": { + "default": "mdi:file-image" + }, + "videos_count": { + "default": "mdi:file-video" + } + } + } +} diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json new file mode 100644 index 00000000000..36c993e9c8f --- /dev/null +++ b/homeassistant/components/immich/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "immich", + "name": "Immich", + "codeowners": ["@mib1185"], + "config_flow": true, + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/immich", + "iot_class": "local_polling", + "loggers": ["aioimmich"], + "quality_scale": "silver", + "requirements": ["aioimmich==0.9.1"] +} diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py new file mode 100644 index 00000000000..caf8264895b --- /dev/null +++ b/homeassistant/components/immich/media_source.py @@ -0,0 +1,263 @@ +"""Immich as a media source.""" + +from __future__ import annotations + +from logging import getLogger + +from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse +from aioimmich.exceptions import ImmichError + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_source import ( + BrowseError, + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, + Unresolvable, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator + +from .const import DOMAIN +from .coordinator import ImmichConfigEntry + +LOGGER = getLogger(__name__) + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Immich media source.""" + hass.http.register_view(ImmichMediaView(hass)) + return ImmichMediaSource(hass) + + +class ImmichMediaSourceIdentifier: + """Immich media item identifier.""" + + def __init__(self, identifier: str) -> None: + """Split identifier into parts.""" + parts = identifier.split("|") + # config_entry.unique_id|collection|collection_id|asset_id|file_name|mime_type + self.unique_id = parts[0] + self.collection = parts[1] if len(parts) > 1 else None + self.collection_id = parts[2] if len(parts) > 2 else None + self.asset_id = parts[3] if len(parts) > 3 else None + self.file_name = parts[4] if len(parts) > 3 else None + self.mime_type = parts[5] if len(parts) > 3 else None + + +class ImmichMediaSource(MediaSource): + """Provide Immich as media sources.""" + + name = "Immich" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize Immich media source.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)): + raise BrowseError("Immich is not configured") + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="Immich", + can_play=False, + can_expand=True, + children_media_class=MediaClass.DIRECTORY, + children=[ + *await self._async_build_immich(item, entries), + ], + ) + + async def _async_build_immich( + self, item: MediaSourceItem, entries: list[ConfigEntry] + ) -> list[BrowseMediaSource]: + """Handle browsing different immich instances.""" + if not item.identifier: + LOGGER.debug("Render all Immich instances") + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=entry.unique_id, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=entry.title, + can_play=False, + can_expand=True, + ) + for entry in entries + ] + identifier = ImmichMediaSourceIdentifier(item.identifier) + entry: ImmichConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, identifier.unique_id + ) + ) + assert entry + immich_api = entry.runtime_data.api + + if identifier.collection is None: + LOGGER.debug("Render all collections for %s", entry.title) + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|albums", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="albums", + can_play=False, + can_expand=True, + ) + ] + + if identifier.collection_id is None: + LOGGER.debug("Render all albums for %s", entry.title) + try: + albums = await immich_api.albums.async_get_all_albums() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|albums|{album.album_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=album.album_name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", + ) + for album in albums + ] + + LOGGER.debug( + "Render all assets of album %s for %s", + identifier.collection_id, + entry.title, + ) + try: + album_info = await immich_api.albums.async_get_album_info( + identifier.collection_id + ) + except ImmichError: + return [] + + ret: list[BrowseMediaSource] = [] + for asset in album_info.assets: + if not (mime_type := asset.original_mime_type) or not mime_type.startswith( + ("image/", "video/") + ): + continue + + if mime_type.startswith("image/"): + media_class = MediaClass.IMAGE + can_play = False + thumb_mime_type = mime_type + else: + media_class = MediaClass.VIDEO + can_play = True + thumb_mime_type = "image/jpeg" + + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=( + f"{identifier.unique_id}|albums|" + f"{identifier.collection_id}|" + f"{asset.asset_id}|" + f"{asset.original_file_name}|" + f"{mime_type}" + ), + media_class=media_class, + media_content_type=mime_type, + title=asset.original_file_name, + can_play=can_play, + can_expand=False, + thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{thumb_mime_type}", + ) + ) + + return ret + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + try: + identifier = ImmichMediaSourceIdentifier(item.identifier) + except IndexError as err: + raise Unresolvable( + f"Could not parse identifier: {item.identifier}" + ) from err + + if identifier.mime_type is None: + raise Unresolvable( + f"Could not resolve identifier that has no mime-type: {item.identifier}" + ) + + return PlayMedia( + ( + f"/immich/{identifier.unique_id}/{identifier.asset_id}/fullsize/{identifier.mime_type}" + ), + identifier.mime_type, + ) + + +class ImmichMediaView(HomeAssistantView): + """Immich Media Finder View.""" + + url = "/immich/{source_dir_id}/{location:.*}" + name = "immich" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the media view.""" + self.hass = hass + + async def get( + self, request: Request, source_dir_id: str, location: str + ) -> Response | StreamResponse: + """Start a GET request.""" + if not self.hass.config_entries.async_loaded_entries(DOMAIN): + raise HTTPNotFound + + try: + asset_id, size, mime_type_base, mime_type_format = location.split("/") + except ValueError as err: + raise HTTPNotFound from err + + entry: ImmichConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, source_dir_id + ) + ) + assert entry + immich_api = entry.runtime_data.api + + # stream response for videos + if mime_type_base == "video": + try: + resp = await immich_api.assets.async_play_video_stream(asset_id) + except ImmichError as exc: + raise HTTPNotFound from exc + stream = ChunkAsyncStreamIterator(resp) + response = StreamResponse() + await response.prepare(request) + async for chunk in stream: + await response.write(chunk) + return response + + # web response for images + try: + image = await immich_api.assets.async_view_asset(asset_id, size) + except ImmichError as exc: + raise HTTPNotFound from exc + return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}") diff --git a/homeassistant/components/immich/quality_scale.yaml b/homeassistant/components/immich/quality_scale.yaml new file mode 100644 index 00000000000..053d51eb8c7 --- /dev/null +++ b/homeassistant/components/immich/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: done + comment: No integration specific actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: done + comment: No integration specific 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: done + comment: No integration specific actions + 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: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + 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: Only one device entry per config entry + entity-category: todo + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair issues needed + stale-devices: + status: exempt + comment: Only one device entry per config entry + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/immich/sensor.py b/homeassistant/components/immich/sensor.py new file mode 100644 index 00000000000..f8eeed2935a --- /dev/null +++ b/homeassistant/components/immich/sensor.py @@ -0,0 +1,147 @@ +"""Sensor platform for the Immich integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import ImmichConfigEntry, ImmichData, ImmichDataUpdateCoordinator +from .entity import ImmichEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class ImmichSensorEntityDescription(SensorEntityDescription): + """Immich sensor entity description.""" + + value: Callable[[ImmichData], StateType] + is_suitable: Callable[[ImmichData], bool] = lambda _: True + + +SENSOR_TYPES: tuple[ImmichSensorEntityDescription, ...] = ( + ImmichSensorEntityDescription( + key="disk_size", + translation_key="disk_size", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_storage.disk_size_raw, + ), + ImmichSensorEntityDescription( + key="disk_available", + translation_key="disk_available", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_storage.disk_available_raw, + ), + ImmichSensorEntityDescription( + key="disk_use", + translation_key="disk_use", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_storage.disk_use_raw, + entity_registry_enabled_default=False, + ), + ImmichSensorEntityDescription( + key="disk_usage", + translation_key="disk_usage", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_storage.disk_usage_percentage, + entity_registry_enabled_default=False, + ), + ImmichSensorEntityDescription( + key="photos_count", + translation_key="photos_count", + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_usage.photos if data.server_usage else None, + is_suitable=lambda data: data.server_usage is not None, + ), + ImmichSensorEntityDescription( + key="videos_count", + translation_key="videos_count", + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_usage.videos if data.server_usage else None, + is_suitable=lambda data: data.server_usage is not None, + ), + ImmichSensorEntityDescription( + key="usage_by_photos", + translation_key="usage_by_photos", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda d: d.server_usage.usage_photos if d.server_usage else None, + is_suitable=lambda data: data.server_usage is not None, + entity_registry_enabled_default=False, + ), + ImmichSensorEntityDescription( + key="usage_by_videos", + translation_key="usage_by_videos", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda d: d.server_usage.usage_videos if d.server_usage else None, + is_suitable=lambda data: data.server_usage is not None, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ImmichConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add immich server state sensors.""" + coordinator = entry.runtime_data + async_add_entities( + ImmichSensorEntity(coordinator, description) + for description in SENSOR_TYPES + if description.is_suitable(coordinator.data) + ) + + +class ImmichSensorEntity(ImmichEntity, SensorEntity): + """Define Immich sensor entity.""" + + entity_description: ImmichSensorEntityDescription + + def __init__( + self, + coordinator: ImmichDataUpdateCoordinator, + description: ImmichSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json new file mode 100644 index 00000000000..875eb79f50b --- /dev/null +++ b/homeassistant/components/immich/strings.json @@ -0,0 +1,73 @@ +{ + "common": { + "data_desc_url": "The full URL of your immich instance.", + "data_desc_api_key": "API key to connect to your immich instance.", + "data_desc_ssl_verify": "Whether to verify the SSL certificate when SSL encryption is used to connect to your immich instance." + }, + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "[%key:component::immich::common::data_desc_url%]", + "api_key": "[%key:component::immich::common::data_desc_api_key%]", + "verify_ssl": "[%key:component::immich::common::data_desc_ssl_verify%]" + } + }, + "reauth_confirm": { + "description": "Update the API key for {name}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::immich::common::data_desc_api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url": "The provided URL is invalid.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The provided API key does not match the configured user.", + "already_configured": "This user is already configured for this immich instance." + } + }, + "entity": { + "sensor": { + "disk_size": { + "name": "Disk size" + }, + "disk_available": { + "name": "Disk available" + }, + "disk_use": { + "name": "Disk used" + }, + "disk_usage": { + "name": "Disk usage" + }, + "photos_count": { + "name": "Photos count", + "unit_of_measurement": "photos" + }, + "videos_count": { + "name": "Videos count", + "unit_of_measurement": "videos" + }, + "usage_by_photos": { + "name": "Disk used by photos" + }, + "usage_by_videos": { + "name": "Disk used by videos" + } + } + } +} diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 6214eb03f40..6ab9f560496 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.8"] + "requirements": ["incomfort-client==0.6.9"] } diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index bc9085c3f20..40673a67609 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -9,7 +9,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "Hostname or IP-address of the Intergas gateway.", + "host": "Hostname or IP address of the Intergas gateway.", "username": "The username to log in to the gateway. This is `admin` in most cases.", "password": "The password to log in to the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." } @@ -49,7 +49,7 @@ "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", "not_found": "No gateway found.", - "timeout_error": "Time out when connecting to the gateway.", + "timeout_error": "Timeout when connecting to the gateway.", "unknown": "Unknown error when connecting to the gateway." } }, @@ -143,7 +143,7 @@ "sensor_fault_s2_e22": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", "sensor_fault_s2_e23": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", "sensor_fault_s2_e24": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", - "shortcut_outside_sensor_temperature_e27": "Shortcut outside sensor temperature", + "shortcut_outside_sensor_temperature_e27": "Shortcut outside temperature sensor", "gas_valve_relay_faulty_e29": "Gas valve relay faulty", "gas_valve_relay_faulty_e30": "[%key:component::incomfort::entity::water_heater::boiler::state::gas_valve_relay_faulty_e29%]" } diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index 95a94cf8fa0..d0cf7c3f8c9 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -338,7 +338,7 @@ def get_influx_connection( # noqa: C901 conf, test_write=False, test_read=False ) -> InfluxClient: """Create the correct influx connection for the API version.""" - kwargs = { + kwargs: dict[str, Any] = { CONF_TIMEOUT: TIMEOUT, } precision = conf.get(CONF_PRECISION) diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json index 55af2b37fb7..fbc6560899a 100644 --- a/homeassistant/components/influxdb/manifest.json +++ b/homeassistant/components/influxdb/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["influxdb", "influxdb_client"], "quality_scale": "legacy", - "requirements": ["influxdb==5.3.1", "influxdb-client==1.24.0"] + "requirements": ["influxdb==5.3.1", "influxdb-client==1.48.0"] } diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 38d406da62e..9c73c4d970f 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -53,5 +53,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.16.1"] + "requirements": ["inkbird-ble==0.16.2"] } diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 4ccf0dec258..0a64ce7140f 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -6,8 +6,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from .const import CONF_SOURCE_SENSOR @@ -21,6 +23,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_SOURCE_SENSOR], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_SOURCE_SENSOR] + ), + source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True diff --git a/homeassistant/components/integration/strings.json b/homeassistant/components/integration/strings.json index ed4f5de3ea7..ddd0d42ca39 100644 --- a/homeassistant/components/integration/strings.json +++ b/homeassistant/components/integration/strings.json @@ -18,7 +18,7 @@ "round": "Controls the number of decimal digits in the output.", "unit_prefix": "The output will be scaled according to the selected metric prefix.", "unit_time": "The output will be scaled according to the selected time unit.", - "max_sub_interval": "Applies time based integration if the source did not change for this duration. Use 0 for no time based updates." + "max_sub_interval": "Applies time-based integration if the source did not change for this duration. Use 0 for no time-based updates." } } } diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index cda30820a2f..cc5da82ab92 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -8,7 +8,6 @@ from intellifire4py import UnifiedFireplace from intellifire4py.cloud_interface import IntelliFireCloudInterface from intellifire4py.model import IntelliFireCommonFireplaceData -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -27,12 +26,11 @@ from .const import ( CONF_SERIAL, CONF_USER_ID, CONF_WEB_CLIENT_ID, - DOMAIN, INIT_WAIT_TIME_SECONDS, LOGGER, STARTUP_TIMEOUT, ) -from .coordinator import IntellifireDataUpdateCoordinator +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -45,7 +43,9 @@ PLATFORMS = [ ] -def _construct_common_data(entry: ConfigEntry) -> IntelliFireCommonFireplaceData: +def _construct_common_data( + entry: IntellifireConfigEntry, +) -> IntelliFireCommonFireplaceData: """Convert config entry data into IntelliFireCommonFireplaceData.""" return IntelliFireCommonFireplaceData( @@ -60,7 +60,9 @@ def _construct_common_data(entry: ConfigEntry) -> IntelliFireCommonFireplaceData ) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: IntellifireConfigEntry +) -> bool: """Migrate entries.""" LOGGER.debug( "Migrating configuration from version %s.%s", @@ -105,7 +107,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry) -> bool: """Set up IntelliFire from a config entry.""" if CONF_USERNAME not in entry.data: @@ -133,7 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Fireplace to Initialized - Awaiting first refresh") await data_update_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_update_coordinator + entry.runtime_data = data_update_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -151,9 +153,8 @@ async def _async_wait_for_initialization( await asyncio.sleep(INIT_WAIT_TIME_SECONDS) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: IntellifireConfigEntry +) -> 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/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index 3da1d2e3dc0..7cc22290e3c 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -10,13 +10,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import IntellifireDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -151,11 +149,11 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a IntelliFire On/Off Sensor.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( IntellifireBinarySensor(coordinator=coordinator, description=description) diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index f067f2a849d..0af438a7374 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -10,13 +10,12 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import IntellifireDataUpdateCoordinator -from .const import DEFAULT_THERMOSTAT_TEMP, DOMAIN, LOGGER +from .const import DEFAULT_THERMOSTAT_TEMP, LOGGER +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity INTELLIFIRE_CLIMATES: tuple[ClimateEntityDescription, ...] = ( @@ -26,11 +25,11 @@ INTELLIFIRE_CLIMATES: tuple[ClimateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure the fan entry..""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.data.has_thermostat: async_add_entities( diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index 6a23e7438db..dc9aa45d58b 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -16,16 +16,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER +type IntellifireConfigEntry = ConfigEntry[IntellifireDataUpdateCoordinator] + class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntelliFirePollData]): """Class to manage the polling of the fireplace API.""" - config_entry: ConfigEntry + config_entry: IntellifireConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IntellifireConfigEntry, fireplace: UnifiedFireplace, ) -> None: """Initialize the Coordinator.""" diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index 174d964d357..3075a5fb2a8 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -15,7 +15,6 @@ from homeassistant.components.fan import ( FanEntityDescription, FanEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -23,8 +22,8 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DOMAIN, LOGGER -from .coordinator import IntellifireDataUpdateCoordinator +from .const import LOGGER +from .coordinator import IntellifireConfigEntry from .entity import IntellifireEntity @@ -57,11 +56,11 @@ INTELLIFIRE_FANS: tuple[IntellifireFanEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fans.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.data.has_fan: async_add_entities( diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index 0cf5c7774ed..c73614bfade 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -15,12 +15,11 @@ from homeassistant.components.light import ( LightEntity, LightEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER -from .coordinator import IntellifireDataUpdateCoordinator +from .const import LOGGER +from .coordinator import IntellifireConfigEntry from .entity import IntellifireEntity @@ -84,11 +83,11 @@ class IntellifireLight(IntellifireEntity, LightEntity): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fans.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.data.has_light: async_add_entities( diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index 0776835833e..68097d30b44 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -9,22 +9,21 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER -from .coordinator import IntellifireDataUpdateCoordinator +from .const import LOGGER +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fans.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data description = NumberEntityDescription( key="flame_control", diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index 7763fb1b9b2..287f9a60ca0 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -12,14 +12,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow -from .const import DOMAIN -from .coordinator import IntellifireDataUpdateCoordinator +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -142,12 +140,12 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Define setup entry call.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( IntelliFireSensor(coordinator=coordinator, description=description) for description in INTELLIFIRE_SENSORS diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index 2185ad47cae..a6ab89d6bd7 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -7,12 +7,10 @@ from dataclasses import dataclass from typing import Any 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 . import IntellifireDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -52,11 +50,11 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure switch entities.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( IntellifireSwitch(coordinator=coordinator, description=description) diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index a04a6ee6377..3465a7e5c07 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -145,7 +145,9 @@ async def async_setup_platform( class IntesisAC(ClimateEntity): """Represents an Intesishome air conditioning device.""" + _attr_preset_modes = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST] _attr_should_poll = False + _attr_target_temperature_step = 1 _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__(self, ih_device_id, ih_device, controller): @@ -153,26 +155,18 @@ class IntesisAC(ClimateEntity): self._controller = controller self._device_id = ih_device_id self._ih_device = ih_device - self._device_name = ih_device.get("name") + self._attr_name = ih_device.get("name") self._device_type = controller.device_type self._connected = None - self._setpoint_step = 1 - self._current_temp = None - self._max_temp = None self._attr_hvac_modes = [] - self._min_temp = None - self._target_temp = None self._outdoor_temp = None self._hvac_mode = None - self._preset = None - self._preset_list = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST] self._run_hours = None self._rssi = None - self._swing_list = [SWING_OFF] + self._attr_swing_modes = [SWING_OFF] self._vvane = None self._hvane = None self._power = False - self._fan_speed = None self._power_consumption_heat = None self._power_consumption_cool = None @@ -182,17 +176,20 @@ class IntesisAC(ClimateEntity): # Setup swing list if controller.has_vertical_swing(ih_device_id): - self._swing_list.append(SWING_VERTICAL) + self._attr_swing_modes.append(SWING_VERTICAL) if controller.has_horizontal_swing(ih_device_id): - self._swing_list.append(SWING_HORIZONTAL) - if SWING_HORIZONTAL in self._swing_list and SWING_VERTICAL in self._swing_list: - self._swing_list.append(SWING_BOTH) - if len(self._swing_list) > 1: + self._attr_swing_modes.append(SWING_HORIZONTAL) + if ( + SWING_HORIZONTAL in self._attr_swing_modes + and SWING_VERTICAL in self._attr_swing_modes + ): + self._attr_swing_modes.append(SWING_BOTH) + if len(self._attr_swing_modes) > 1: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE # Setup fan speeds - self._fan_modes = controller.get_fan_speed_list(ih_device_id) - if self._fan_modes: + self._attr_fan_modes = controller.get_fan_speed_list(ih_device_id) + if self._attr_fan_modes: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE # Preset support @@ -220,11 +217,6 @@ class IntesisAC(ClimateEntity): _LOGGER.error("Exception connecting to IntesisHome: %s", ex) raise PlatformNotReady from ex - @property - def name(self): - """Return the name of the AC device.""" - return self._device_name - @property def extra_state_attributes(self): """Return the device specific state attributes.""" @@ -247,21 +239,6 @@ class IntesisAC(ClimateEntity): """Return unique ID for this device.""" return self._device_id - @property - def target_temperature_step(self) -> float: - """Return whether setpoint should be whole or half degree precision.""" - return self._setpoint_step - - @property - def preset_modes(self): - """Return a list of HVAC preset modes.""" - return self._preset_list - - @property - def preset_mode(self): - """Return the current preset mode.""" - return self._preset - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if hvac_mode := kwargs.get(ATTR_HVAC_MODE): @@ -270,7 +247,7 @@ class IntesisAC(ClimateEntity): if temperature := kwargs.get(ATTR_TEMPERATURE): _LOGGER.debug("Setting %s to %s degrees", self._device_type, temperature) await self._controller.set_temperature(self._device_id, temperature) - self._target_temp = temperature + self._attr_target_temperature = temperature # Write updated temperature to HA state to avoid flapping (API confirmation is slow) self.async_write_ha_state() @@ -294,8 +271,10 @@ class IntesisAC(ClimateEntity): await self._controller.set_mode(self._device_id, MAP_HVAC_MODE_TO_IH[hvac_mode]) # Send the temperature again in case changing modes has changed it - if self._target_temp: - await self._controller.set_temperature(self._device_id, self._target_temp) + if self._attr_target_temperature: + await self._controller.set_temperature( + self._device_id, self._attr_target_temperature + ) # Updates can take longer than 2 seconds, so update locally self._hvac_mode = hvac_mode @@ -306,7 +285,7 @@ class IntesisAC(ClimateEntity): await self._controller.set_fan_speed(self._device_id, fan_mode) # Updates can take longer than 2 seconds, so update locally - self._fan_speed = fan_mode + self._attr_fan_mode = fan_mode self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -328,14 +307,16 @@ class IntesisAC(ClimateEntity): """Copy values from controller dictionary to climate device.""" # Update values from controller's device dictionary self._connected = self._controller.is_connected - self._current_temp = self._controller.get_temperature(self._device_id) - self._fan_speed = self._controller.get_fan_speed(self._device_id) + self._attr_current_temperature = self._controller.get_temperature( + self._device_id + ) + self._attr_fan_mode = self._controller.get_fan_speed(self._device_id) self._power = self._controller.is_on(self._device_id) - self._min_temp = self._controller.get_min_setpoint(self._device_id) - self._max_temp = self._controller.get_max_setpoint(self._device_id) + self._attr_min_temp = self._controller.get_min_setpoint(self._device_id) + self._attr_max_temp = self._controller.get_max_setpoint(self._device_id) self._rssi = self._controller.get_rssi(self._device_id) self._run_hours = self._controller.get_run_hours(self._device_id) - self._target_temp = self._controller.get_setpoint(self._device_id) + self._attr_target_temperature = self._controller.get_setpoint(self._device_id) self._outdoor_temp = self._controller.get_outdoor_temperature(self._device_id) # Operation mode @@ -344,7 +325,7 @@ class IntesisAC(ClimateEntity): # Preset mode preset = self._controller.get_preset_mode(self._device_id) - self._preset = MAP_IH_TO_PRESET_MODE.get(preset) + self._attr_preset_mode = MAP_IH_TO_PRESET_MODE.get(preset) # Swing mode # Climate module only supports one swing setting. @@ -364,12 +345,11 @@ class IntesisAC(ClimateEntity): await self._controller.stop() @property - def icon(self): + def icon(self) -> str | None: """Return the icon for the current state.""" - icon = None if self._power: - icon = MAP_STATE_ICONS.get(self._hvac_mode) - return icon + return MAP_STATE_ICONS.get(self._hvac_mode) + return None async def async_update_callback(self, device_id=None): """Let HA know there has been an update from the controller.""" @@ -405,22 +385,7 @@ class IntesisAC(ClimateEntity): self.async_schedule_update_ha_state(True) @property - def min_temp(self): - """Return the minimum temperature for the current mode of operation.""" - return self._min_temp - - @property - def max_temp(self): - """Return the maximum temperature for the current mode of operation.""" - return self._max_temp - - @property - def fan_mode(self): - """Return whether the fan is on.""" - return self._fan_speed - - @property - def swing_mode(self): + def swing_mode(self) -> str: """Return current swing mode.""" if self._vvane == IH_SWING_SWING and self._hvane == IH_SWING_SWING: swing = SWING_BOTH @@ -432,34 +397,14 @@ class IntesisAC(ClimateEntity): swing = SWING_OFF return swing - @property - def fan_modes(self): - """List of available fan modes.""" - return self._fan_modes - - @property - def swing_modes(self): - """List of available swing positions.""" - return self._swing_list - @property def available(self) -> bool: """If the device hasn't been able to connect, mark as unavailable.""" return self._connected or self._connected is None - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temp - @property def hvac_mode(self) -> HVACMode: """Return the current mode of operation if unit is on.""" if self._power: return self._hvac_mode return HVACMode.OFF - - @property - def target_temperature(self): - """Return the current setpoint temperature if unit is on.""" - return self._target_temp diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py index 4050341151b..e5d2b554a89 100644 --- a/homeassistant/components/iometer/coordinator.py +++ b/homeassistant/components/iometer/coordinator.py @@ -9,6 +9,7 @@ from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -48,6 +49,9 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): config_entry=config_entry, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=1.0, immediate=False + ), ) self.client = client self.identifier = config_entry.entry_id diff --git a/homeassistant/components/iotawatt/__init__.py b/homeassistant/components/iotawatt/__init__.py index 8f35d4e0796..1dc38ba01c6 100644 --- a/homeassistant/components/iotawatt/__init__.py +++ b/homeassistant/components/iotawatt/__init__.py @@ -1,26 +1,22 @@ """The iotawatt integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import IotawattUpdater +from .coordinator import IotawattConfigEntry, IotawattUpdater PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IotawattConfigEntry) -> bool: """Set up iotawatt from a config entry.""" coordinator = IotawattUpdater(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IotawattConfigEntry) -> 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/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py index 13802ebdd76..48d55dad818 100644 --- a/homeassistant/components/iotawatt/coordinator.py +++ b/homeassistant/components/iotawatt/coordinator.py @@ -21,14 +21,16 @@ _LOGGER = logging.getLogger(__name__) # Matches iotwatt data log interval REQUEST_REFRESH_DEFAULT_COOLDOWN = 5 +type IotawattConfigEntry = ConfigEntry[IotawattUpdater] + class IotawattUpdater(DataUpdateCoordinator): """Class to manage fetching update data from the IoTaWatt Energy Device.""" api: Iotawatt | None = None - config_entry: ConfigEntry + config_entry: IotawattConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: IotawattConfigEntry) -> None: """Initialize IotaWattUpdater object.""" super().__init__( hass=hass, diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index f5210f7fbba..591397ad6e7 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfApparentPower, @@ -31,8 +30,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS -from .coordinator import IotawattUpdater +from .const import VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .coordinator import IotawattConfigEntry, IotawattUpdater _LOGGER = logging.getLogger(__name__) @@ -113,11 +112,11 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IotawattConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors for passed config_entry in HA.""" - coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data created = set() @callback diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index 27b3eac26b5..9ba3b55ed4f 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ATTR_VERSION, DATA_UPDATED, DOMAIN as IPERF3_DOMAIN, SENSOR_TYPES +from . import ATTR_VERSION, DATA_UPDATED, DOMAIN, SENSOR_TYPES ATTR_PROTOCOL = "Protocol" ATTR_REMOTE_HOST = "Remote Server" @@ -29,7 +29,7 @@ async def async_setup_platform( entities = [ Iperf3Sensor(iperf3_host, description) - for iperf3_host in hass.data[IPERF3_DOMAIN].values() + for iperf3_host in hass.data[DOMAIN].values() for description in SENSOR_TYPES if description.key in discovery_info[CONF_MONITORED_CONDITIONS] ] diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index 68289d13289..6c48ae4c925 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -1,6 +1,7 @@ """Component for the Portuguese weather service - IPMA.""" import asyncio +from dataclasses import dataclass import logging from pyipma import IPMAException @@ -14,7 +15,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .config_flow import IpmaFlowHandler # noqa: F401 -from .const import DATA_API, DATA_LOCATION, DOMAIN DEFAULT_NAME = "ipma" @@ -22,8 +22,18 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER] _LOGGER = logging.getLogger(__name__) +type IpmaConfigEntry = ConfigEntry[IpmaRuntimeData] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +@dataclass +class IpmaRuntimeData: + """IPMA runtime data.""" + + api: IPMA_API + location: Location + + +async def async_setup_entry(hass: HomeAssistant, config_entry: IpmaConfigEntry) -> bool: """Set up IPMA station as config entry.""" latitude = config_entry.data[CONF_LATITUDE] @@ -48,20 +58,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b location.global_id_local, ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = {DATA_API: api, DATA_LOCATION: location} + config_entry.runtime_data = IpmaRuntimeData(api=api, location=location) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IpmaConfigEntry) -> bool: """Unload a config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py index dd6f1fba64a..1cb1af17d95 100644 --- a/homeassistant/components/ipma/const.py +++ b/homeassistant/components/ipma/const.py @@ -27,9 +27,6 @@ DOMAIN = "ipma" HOME_LOCATION_NAME = "Home" -DATA_API = "api" -DATA_LOCATION = "location" - ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) diff --git a/homeassistant/components/ipma/diagnostics.py b/homeassistant/components/ipma/diagnostics.py index 948b69ee3e5..bf868324593 100644 --- a/homeassistant/components/ipma/diagnostics.py +++ b/homeassistant/components/ipma/diagnostics.py @@ -4,20 +4,19 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from .const import DATA_API, DATA_LOCATION, DOMAIN +from . import IpmaConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: IpmaConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - location = hass.data[DOMAIN][entry.entry_id][DATA_LOCATION] - api = hass.data[DOMAIN][entry.entry_id][DATA_API] + location = entry.runtime_data.location + api = entry.runtime_data.api return { "location_information": { diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 78fd018cf9a..7e71457513b 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -14,12 +14,12 @@ from pyipma.rcm import RCM from pyipma.uv import UV from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle -from .const import DATA_API, DATA_LOCATION, DOMAIN, MIN_TIME_BETWEEN_UPDATES +from . import IpmaConfigEntry +from .const import MIN_TIME_BETWEEN_UPDATES from .entity import IPMADevice _LOGGER = logging.getLogger(__name__) @@ -87,12 +87,12 @@ SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IpmaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the IPMA sensor platform.""" - api = hass.data[DOMAIN][entry.entry_id][DATA_API] - location = hass.data[DOMAIN][entry.entry_id][DATA_LOCATION] + location = entry.runtime_data.location + api = entry.runtime_data.api entities = [IPMASensor(api, location, description) for description in SENSOR_TYPES] diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index d285f9e1ad3..74344da8aff 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -23,7 +23,6 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_MODE, UnitOfPressure, @@ -35,14 +34,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle -from .const import ( - ATTRIBUTION, - CONDITION_MAP, - DATA_API, - DATA_LOCATION, - DOMAIN, - MIN_TIME_BETWEEN_UPDATES, -) +from . import IpmaConfigEntry +from .const import ATTRIBUTION, CONDITION_MAP, MIN_TIME_BETWEEN_UPDATES from .entity import IPMADevice _LOGGER = logging.getLogger(__name__) @@ -50,12 +43,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IpmaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - api = hass.data[DOMAIN][config_entry.entry_id][DATA_API] - location = hass.data[DOMAIN][config_entry.entry_id][DATA_LOCATION] + location = config_entry.runtime_data.location + api = config_entry.runtime_data.api async_add_entities([IPMAWeather(api, location, config_entry)], True) @@ -72,7 +65,7 @@ class IPMAWeather(WeatherEntity, IPMADevice): ) def __init__( - self, api: IPMA_API, location: Location, config_entry: ConfigEntry + self, api: IPMA_API, location: Location, config_entry: IpmaConfigEntry ) -> None: """Initialise the platform with a data instance and station name.""" IPMADevice.__init__(self, api, location) diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 3fabb88b041..ad8b78bf9e3 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -3,25 +3,16 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from datetime import timedelta -from functools import partial -from typing import Any from pyiqvia import Client -from pyiqvia.errors import IQVIAError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_ZIP_CODE, - DOMAIN, - LOGGER, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, TYPE_ALLERGY_OUTLOOK, @@ -30,14 +21,14 @@ from .const import ( TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX, ) +from .coordinator import IqviaConfigEntry, IqviaUpdateCoordinator DEFAULT_ATTRIBUTION = "Data provided by IQVIA™" -DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IqviaConfigEntry) -> bool: """Set up IQVIA as config entry.""" if not entry.unique_id: # If the config entry doesn't already have a unique ID, set one: @@ -52,15 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # blocking) startup: client.disable_request_retries() - async def async_get_data_from_api( - api_coro: Callable[[], Coroutine[Any, Any, dict[str, Any]]], - ) -> dict[str, Any]: - """Get data from a particular API coroutine.""" - try: - return await api_coro() - except IQVIAError as err: - raise UpdateFailed from err - coordinators = {} init_data_update_tasks = [] @@ -73,13 +55,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: (TYPE_DISEASE_FORECAST, client.disease.extended), (TYPE_DISEASE_INDEX, client.disease.current), ): - coordinator = coordinators[sensor_type] = DataUpdateCoordinator( + coordinator = coordinators[sensor_type] = IqviaUpdateCoordinator( hass, - LOGGER, config_entry=entry, name=f"{entry.data[CONF_ZIP_CODE]} {sensor_type}", - update_interval=DEFAULT_SCAN_INTERVAL, - update_method=partial(async_get_data_from_api, api_coro), + update_method=api_coro, ) init_data_update_tasks.append(coordinator.async_refresh()) @@ -93,18 +73,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Once we've successfully authenticated, we re-enable client request retries: client.enable_request_retries() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinators + entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IqviaConfigEntry) -> bool: """Unload an OpenUV 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/iqvia/coordinator.py b/homeassistant/components/iqvia/coordinator.py new file mode 100644 index 00000000000..ef926d1112d --- /dev/null +++ b/homeassistant/components/iqvia/coordinator.py @@ -0,0 +1,49 @@ +"""Support for IQVIA.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from datetime import timedelta +from typing import Any + +from pyiqvia.errors import IQVIAError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +type IqviaConfigEntry = ConfigEntry[dict[str, IqviaUpdateCoordinator]] + + +class IqviaUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Custom DataUpdateCoordinator for IQVIA.""" + + config_entry: IqviaConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: IqviaConfigEntry, + name: str, + update_method: Callable[[], Coroutine[Any, Any, dict[str, Any]]], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=name, + config_entry=config_entry, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._update_method = update_method + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the API.""" + try: + return await self._update_method() + except IQVIAError as err: + raise UpdateFailed from err diff --git a/homeassistant/components/iqvia/diagnostics.py b/homeassistant/components/iqvia/diagnostics.py index 64827f183ff..953d42eafc2 100644 --- a/homeassistant/components/iqvia/diagnostics.py +++ b/homeassistant/components/iqvia/diagnostics.py @@ -5,12 +5,11 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_ZIP_CODE, DOMAIN +from .const import CONF_ZIP_CODE +from .coordinator import IqviaConfigEntry CONF_CITY = "City" CONF_DISPLAY_LOCATION = "DisplayLocation" @@ -33,19 +32,15 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: IqviaConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators: dict[str, DataUpdateCoordinator[dict[str, Any]]] = hass.data[DOMAIN][ - entry.entry_id - ] - return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), "data": async_redact_data( { data_type: coordinator.data - for data_type, coordinator in coordinators.items() + for data_type, coordinator in entry.runtime_data.items() }, TO_REDACT, ), diff --git a/homeassistant/components/iqvia/entity.py b/homeassistant/components/iqvia/entity.py index e77c0f7e32a..04e92ef9c4d 100644 --- a/homeassistant/components/iqvia/entity.py +++ b/homeassistant/components/iqvia/entity.py @@ -2,28 +2,23 @@ from __future__ import annotations -from typing import Any - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_ZIP_CODE, DOMAIN, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK +from .const import CONF_ZIP_CODE, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK +from .coordinator import IqviaConfigEntry, IqviaUpdateCoordinator -class IQVIAEntity(CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]]): +class IQVIAEntity(CoordinatorEntity[IqviaUpdateCoordinator]): """Define a base IQVIA entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[dict[str, Any]], - entry: ConfigEntry, + coordinator: IqviaUpdateCoordinator, + entry: IqviaConfigEntry, description: EntityDescription, ) -> None: """Initialize.""" @@ -49,9 +44,9 @@ class IQVIAEntity(CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]]): if self.entity_description.key == TYPE_ALLERGY_FORECAST: self.async_on_remove( - self.hass.data[DOMAIN][self._entry.entry_id][ - TYPE_ALLERGY_OUTLOOK - ].async_add_listener(self._handle_coordinator_update) + self._entry.runtime_data[TYPE_ALLERGY_OUTLOOK].async_add_listener( + self._handle_coordinator_update + ) ) self.update_from_latest_data() diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 64492c634e9..8b838d35ea1 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -12,13 +12,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, TYPE_ALLERGY_OUTLOOK, @@ -32,6 +30,7 @@ from .const import ( TYPE_DISEASE_INDEX, TYPE_DISEASE_TODAY, ) +from .coordinator import IqviaConfigEntry from .entity import IQVIAEntity ATTR_ALLERGEN_AMOUNT = "allergen_amount" @@ -128,13 +127,13 @@ INDEX_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IqviaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up IQVIA sensors based on a config entry.""" sensors: list[ForecastSensor | IndexSensor] = [ ForecastSensor( - hass.data[DOMAIN][entry.entry_id][ + entry.runtime_data[ API_CATEGORY_MAPPING.get(description.key, description.key) ], entry, @@ -145,7 +144,7 @@ async def async_setup_entry( sensors.extend( [ IndexSensor( - hass.data[DOMAIN][entry.entry_id][ + entry.runtime_data[ API_CATEGORY_MAPPING.get(description.key, description.key) ], entry, @@ -207,9 +206,7 @@ class ForecastSensor(IQVIAEntity, SensorEntity): ) if self.entity_description.key == TYPE_ALLERGY_FORECAST: - outlook_coordinator = self.hass.data[DOMAIN][self._entry.entry_id][ - TYPE_ALLERGY_OUTLOOK - ] + outlook_coordinator = self._entry.runtime_data[TYPE_ALLERGY_OUTLOOK] if not outlook_coordinator.last_update_success: return diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json index caa176ab6b6..da983db9969 100644 --- a/homeassistant/components/iskra/manifest.json +++ b/homeassistant/components/iskra/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyiskra"], - "requirements": ["pyiskra==0.1.15"] + "requirements": ["pyiskra==0.1.21"] } diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 1e227b08206..bed86b2d0fe 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -10,7 +10,6 @@ from pyisy import ISY, ISYConnectionError, ISYInvalidAuthError, ISYResponseParse from pyisy.constants import CONFIG_NETWORKING, CONFIG_PORTAL import voluptuous as vol -from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -46,7 +45,7 @@ from .const import ( SCHEME_HTTPS, ) from .helpers import _categorize_nodes, _categorize_programs -from .models import IsyData +from .models import IsyConfigEntry, IsyData from .services import async_setup_services, async_unload_services from .util import _async_cleanup_registry_entries @@ -56,13 +55,8 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: """Set up the ISY 994 integration.""" - hass.data.setdefault(DOMAIN, {}) - isy_data = hass.data[DOMAIN][entry.entry_id] = IsyData() - isy_config = entry.data isy_options = entry.options @@ -127,6 +121,7 @@ async def async_setup_entry( f"Invalid response ISY, device is likely still starting: {err}" ) from err + isy_data = entry.runtime_data = IsyData() _categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier) _categorize_programs(isy_data, isy.programs) # Gather ISY Variables to be added. @@ -156,7 +151,7 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Clean-up any old entities that we no longer provide. - _async_cleanup_registry_entries(hass, entry.entry_id) + _async_cleanup_registry_entries(hass, entry) @callback def _async_stop_auto_update(event: Event) -> None: @@ -178,16 +173,14 @@ async def async_setup_entry( return True -async def _async_update_listener( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: IsyConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) @callback def _async_get_or_create_isy_device_in_registry( - hass: HomeAssistant, entry: config_entries.ConfigEntry, isy: ISY + hass: HomeAssistant, entry: IsyConfigEntry, isy: ISY ) -> None: device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -221,34 +214,25 @@ def _create_service_device_info(isy: ISY, name: str, unique_id: str) -> DeviceIn ) -async def async_unload_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - - isy = isy_data.root - _LOGGER.debug("ISY Stopping Event Stream and automatic updates") - isy.websocket.stop() + entry.runtime_data.root.websocket.stop() - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - async_unload_services(hass) + if not hass.config_entries.async_loaded_entries(DOMAIN): + async_unload_services(hass) return unload_ok async def async_remove_config_entry_device( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: IsyConfigEntry, device_entry: dr.DeviceEntry, ) -> bool: """Remove ISY config entry from a device.""" - isy_data = hass.data[DOMAIN][config_entry.entry_id] return not device_entry.identifiers.intersection( - (DOMAIN, unique_id) for unique_id in isy_data.devices + (DOMAIN, unique_id) for unique_id in config_entry.runtime_data.devices ) diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 8c9ce7dcc12..d452b5bacef 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -19,7 +19,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -31,7 +30,6 @@ from .const import ( _LOGGER, BINARY_SENSOR_DEVICE_TYPES_ISY, BINARY_SENSOR_DEVICE_TYPES_ZWAVE, - DOMAIN, SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, SUBNODE_DUSK_DAWN, @@ -44,7 +42,7 @@ from .const import ( TYPE_INSTEON_MOTION, ) from .entity import ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry DEVICE_PARENT_REQUIRED = [ BinarySensorDeviceClass.OPENING, @@ -55,7 +53,7 @@ DEVICE_PARENT_REQUIRED = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY binary sensor platform.""" @@ -82,8 +80,8 @@ async def async_setup_entry( | ISYBinarySensorProgramEntity ) - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices for node in isy_data.nodes[Platform.BINARY_SENSOR]: assert isinstance(node, Node) device_info = devices.get(node.primary_node) diff --git a/homeassistant/components/isy994/button.py b/homeassistant/components/isy994/button.py index a895312c45a..cfb077c7dc0 100644 --- a/homeassistant/components/isy994/button.py +++ b/homeassistant/components/isy994/button.py @@ -15,24 +15,23 @@ from pyisy.networking import NetworkCommand from pyisy.nodes import Node from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_NETWORK, DOMAIN -from .models import IsyData +from .models import IsyConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ISY/IoX button from config entry.""" - isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] - isy: ISY = isy_data.root + isy_data = config_entry.runtime_data + isy = isy_data.root device_info = isy_data.devices entities: list[ ISYNodeQueryButtonEntity diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 57c1b6aa79d..ce39cae5428 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -28,7 +28,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_TENTHS, @@ -42,7 +41,6 @@ from homeassistant.util.enum import try_parse_enum from .const import ( _LOGGER, - DOMAIN, HA_FAN_TO_ISY, HA_HVAC_TO_ISY, ISY_HVAC_MODES, @@ -57,18 +55,18 @@ from .const import ( ) from .entity import ISYNodeEntity from .helpers import convert_isy_value_to_hass -from .models import IsyData +from .models import IsyConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY thermostat platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices async_add_entities( ISYThermostatEntity(node, devices.get(node.primary_node)) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index b44096e2ccd..2acebee8599 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_IGNORE, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -54,6 +53,7 @@ from .const import ( SCHEME_HTTPS, UDN_UUID_PREFIX, ) +from .models import IsyConfigEntry _LOGGER = logging.getLogger(__name__) @@ -137,12 +137,12 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the ISY/IoX config flow.""" self.discovered_conf: dict[str, str] = {} - self._existing_entry: ConfigEntry | None = None + self._existing_entry: IsyConfigEntry | None = None @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: IsyConfigEntry, ) -> OptionsFlow: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index 6a660aaaf6f..f940fe55332 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -11,25 +11,23 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE +from .const import _LOGGER, UOM_8_BIT_RANGE from .entity import ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY cover platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices entities: list[ISYCoverEntity | ISYCoverProgramEntity] = [ ISYCoverEntity(node, devices.get(node.primary_node)) for node in isy_data.nodes[Platform.COVER] diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index aa6059abf49..02542462788 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -8,10 +8,8 @@ from typing import Any from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_INSTEON from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -19,21 +17,21 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .const import _LOGGER, DOMAIN +from .const import _LOGGER from .entity import ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry SPEED_RANGE = (1, 255) # off is not included async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY fan platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices entities: list[ISYFanEntity | ISYFanProgramEntity] = [ ISYFanEntity(node, devices.get(node.primary_node)) for node in isy_data.nodes[Platform.FAN] diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index 29df8398f97..d3edc25c3e2 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -9,28 +9,27 @@ from pyisy.helpers import NodeProperty from pyisy.nodes import Node from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, DOMAIN, UOM_PERCENTAGE +from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, UOM_PERCENTAGE from .entity import ISYNodeEntity -from .models import IsyData +from .models import IsyConfigEntry ATTR_LAST_BRIGHTNESS = "last_brightness" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY light platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices isy_options = entry.options restore_light_state = isy_options.get(CONF_RESTORE_LIGHT_STATE, False) diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index d6866a8e00c..056d1d0d492 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -7,19 +7,16 @@ from typing import Any from pyisy.constants import ISY_VALUE_UNKNOWN from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, async_get_current_platform, ) -from .const import DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry from .services import ( SERVICE_DELETE_USER_CODE_SCHEMA, SERVICE_DELETE_ZWAVE_LOCK_USER_CODE, @@ -49,12 +46,12 @@ def async_setup_lock_services(hass: HomeAssistant) -> None: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY lock platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices entities: list[ISYLockEntity | ISYLockProgramEntity] = [ ISYLockEntity(node, devices.get(node.primary_node)) for node in isy_data.nodes[Platform.LOCK] diff --git a/homeassistant/components/isy994/models.py b/homeassistant/components/isy994/models.py index 5b599df9458..4fc7b96fcd5 100644 --- a/homeassistant/components/isy994/models.py +++ b/homeassistant/components/isy994/models.py @@ -12,6 +12,7 @@ from pyisy.nodes import Group, Node from pyisy.programs import Program from pyisy.variables import Variable +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.helpers.device_registry import DeviceInfo @@ -24,6 +25,8 @@ from .const import ( VARIABLE_PLATFORMS, ) +type IsyConfigEntry = ConfigEntry[IsyData] + @dataclass class IsyData: diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index fc30e6296d4..c5797491e31 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -26,7 +26,6 @@ from homeassistant.components.number import ( NumberMode, RestoreNumber, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_VARIABLES, PERCENTAGE, @@ -44,15 +43,10 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import ( - CONF_VAR_SENSOR_STRING, - DEFAULT_VAR_SENSOR_STRING, - DOMAIN, - UOM_8_BIT_RANGE, -) +from .const import CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING, UOM_8_BIT_RANGE from .entity import ISYAuxControlEntity from .helpers import convert_isy_value_to_hass -from .models import IsyData +from .models import IsyConfigEntry ISY_MAX_SIZE = (2**32) / 2 ON_RANGE = (1, 255) # Off is not included @@ -79,11 +73,11 @@ BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ISY/IoX number entities from config entry.""" - isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] + isy_data = config_entry.runtime_data device_info = isy_data.devices entities: list[ ISYVariableNumberEntity | ISYAuxControlNumberEntity | ISYBacklightNumberEntity diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index 868c96375bb..ce5e224bc88 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -23,7 +23,6 @@ from pyisy.helpers import EventListener, NodeProperty from pyisy.nodes import Node, NodeChangedEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -37,9 +36,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import _LOGGER, DOMAIN, UOM_INDEX +from .const import _LOGGER, UOM_INDEX from .entity import ISYAuxControlEntity -from .models import IsyData +from .models import IsyConfigEntry def time_string(i: int) -> str: @@ -55,11 +54,11 @@ BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ISY/IoX select entities from config entry.""" - isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] + isy_data = config_entry.runtime_data device_info = isy_data.devices entities: list[ ISYAuxControlIndexSelectEntity diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 2d27f4602c6..6e0b5a89637 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -29,7 +29,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -37,7 +36,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( _LOGGER, - DOMAIN, UOM_DOUBLE_TEMP, UOM_FRIENDLY_NAME, UOM_INDEX, @@ -46,7 +44,7 @@ from .const import ( ) from .entity import ISYNodeEntity from .helpers import convert_isy_value_to_hass -from .models import IsyData +from .models import IsyConfigEntry # Disable general purpose and redundant sensors by default AUX_DISABLED_BY_DEFAULT_MATCH = ["GV", "DO"] @@ -109,13 +107,13 @@ ISY_CONTROL_TO_ENTITY_CATEGORY = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY sensor platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] + isy_data = entry.runtime_data entities: list[ISYSensorEntity] = [] - devices: dict[str, DeviceInfo] = isy_data.devices + devices = isy_data.devices for node in isy_data.nodes[Platform.SENSOR]: _LOGGER.debug("Loading %s", node.name) diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 24cfa9aefb1..39f72a5cc2c 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -21,7 +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 +from .models import IsyConfigEntry # Common Services for All Platforms: SERVICE_SEND_PROGRAM_COMMAND = "send_program_command" @@ -149,9 +149,9 @@ def async_setup_services(hass: HomeAssistant) -> None: command = service.data[CONF_COMMAND] isy_name = service.data.get(CONF_ISY) - for config_entry_id in hass.data[DOMAIN]: - isy_data: IsyData = hass.data[DOMAIN][config_entry_id] - isy = isy_data.root + config_entry: IsyConfigEntry + for config_entry in hass.config_entries.async_loaded_entries(DOMAIN): + isy = config_entry.runtime_data.root if isy_name and isy_name != isy.conf["name"]: continue program = None @@ -235,10 +235,6 @@ def async_setup_services(hass: HomeAssistant) -> None: @callback def async_unload_services(hass: HomeAssistant) -> None: """Unload services for the ISY integration.""" - if hass.data[DOMAIN]: - # There is still another config entry for this domain, don't remove services. - return - existing_services = hass.services.async_services_for_domain(DOMAIN) if not existing_services or SERVICE_SEND_PROGRAM_COMMAND not in existing_services: return diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 6594c030f08..73f6cc98b12 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -36,7 +36,7 @@ "options": { "step": { "init": { - "title": "ISY Options", + "title": "ISY options", "description": "Set the options for the ISY integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Variable Sensor String: Any variable that contains 'Variable Sensor String' will be added as a sensor. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.", "data": { "sensor_string": "Node Sensor String", @@ -49,10 +49,10 @@ }, "system_health": { "info": { - "host_reachable": "Host Reachable", - "device_connected": "ISY Connected", - "last_heartbeat": "Last Heartbeat Time", - "websocket_status": "Event Socket Status" + "host_reachable": "Host reachable", + "device_connected": "ISY connected", + "last_heartbeat": "Last heartbeat time", + "websocket_status": "Event socket status" } }, "services": { @@ -89,7 +89,7 @@ } }, "get_zwave_parameter": { - "name": "Get Z-Wave Parameter", + "name": "Get Z-Wave parameter", "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": { @@ -164,7 +164,7 @@ }, "command": { "name": "Command", - "description": "The ISY Program Command to be sent." + "description": "The ISY program command to be sent." }, "isy": { "name": "ISY", diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index d5c8a23cbea..f44613317c5 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -20,16 +20,14 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import ISYAuxControlEntity, ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry @dataclass(frozen=True) @@ -43,11 +41,11 @@ class ISYSwitchEntityDescription(SwitchEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY switch platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] + isy_data = entry.runtime_data entities: list[ ISYSwitchProgramEntity | ISYSwitchEntity | ISYEnableSwitchEntity ] = [] diff --git a/homeassistant/components/isy994/system_health.py b/homeassistant/components/isy994/system_health.py index dfc45c267dd..9c5a04ba34a 100644 --- a/homeassistant/components/isy994/system_health.py +++ b/homeassistant/components/isy994/system_health.py @@ -4,15 +4,12 @@ from __future__ import annotations from typing import Any -from pyisy import ISY - from homeassistant.components import system_health -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from .const import DOMAIN, ISY_URL_POSTFIX -from .models import IsyData +from .models import IsyConfigEntry @callback @@ -27,14 +24,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" health_info = {} - config_entry_id = next( - iter(hass.data[DOMAIN]) - ) # Only first ISY is supported for now - isy_data: IsyData = hass.data[DOMAIN][config_entry_id] - isy: ISY = isy_data.root + entry: IsyConfigEntry = hass.config_entries.async_loaded_entries(DOMAIN)[0] + isy = entry.runtime_data.root - entry = hass.config_entries.async_get_entry(config_entry_id) - assert isinstance(entry, ConfigEntry) health_info["host_reachable"] = await system_health.async_check_can_reach_url( hass, f"{entry.data[CONF_HOST]}{ISY_URL_POSTFIX}" ) diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py index ca5c5ea46a9..87cb450d08b 100644 --- a/homeassistant/components/isy994/util.py +++ b/homeassistant/components/isy994/util.py @@ -5,16 +5,19 @@ from __future__ import annotations from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from .const import _LOGGER, DOMAIN +from .const import _LOGGER +from .models import IsyConfigEntry @callback -def _async_cleanup_registry_entries(hass: HomeAssistant, entry_id: str) -> None: +def _async_cleanup_registry_entries(hass: HomeAssistant, entry: IsyConfigEntry) -> None: """Remove extra entities that are no longer part of the integration.""" entity_registry = er.async_get(hass) - isy_data = hass.data[DOMAIN][entry_id] + isy_data = entry.runtime_data - existing_entries = er.async_entries_for_config_entry(entity_registry, entry_id) + existing_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) entities = { (entity.domain, entity.unique_id): entity.entity_id for entity in existing_entries @@ -31,5 +34,5 @@ def _async_cleanup_registry_entries(hass: HomeAssistant, entry_id: str) -> None: _LOGGER.debug( ("Cleaning up ISY entities: removed %s extra entities for config entry %s"), len(extra_entities), - entry_id, + entry.entry_id, ) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 1cb6219ada0..d22594070ff 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -7,7 +7,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, PLATFORMS +from .const import CONF_CLIENT_DEVICE_ID, DEFAULT_NAME, DOMAIN, PLATFORMS from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator @@ -35,9 +35,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> coordinator = JellyfinDataUpdateCoordinator( hass, entry, client, server_info, user_id ) - await coordinator.async_config_entry_first_refresh() + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.server_id)}, + manufacturer=DEFAULT_NAME, + name=coordinator.server_name, + sw_version=coordinator.server_version, + ) + entry.runtime_data = coordinator entry.async_on_unload(client.stop) diff --git a/homeassistant/components/jellyfin/entity.py b/homeassistant/components/jellyfin/entity.py index 4a3b2b77bb1..107a67d6a89 100644 --- a/homeassistant/components/jellyfin/entity.py +++ b/homeassistant/components/jellyfin/entity.py @@ -4,10 +4,10 @@ from __future__ import annotations from typing import Any -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN from .coordinator import JellyfinDataUpdateCoordinator @@ -24,11 +24,7 @@ class JellyfinServerEntity(JellyfinEntity): """Initialize the Jellyfin entity.""" super().__init__(coordinator) self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.server_id)}, - manufacturer=DEFAULT_NAME, - name=coordinator.server_name, - sw_version=coordinator.server_version, ) diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index d8672e8a4a3..79b49050cc2 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -20,6 +20,8 @@ from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): @@ -38,19 +40,18 @@ class JewishCalendarBinarySensorEntityDescription( BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( JewishCalendarBinarySensorEntityDescription( key="issur_melacha_in_effect", - name="Issur Melacha in Effect", - icon="mdi:power-plug-off", + translation_key="issur_melacha_in_effect", is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)), ), JewishCalendarBinarySensorEntityDescription( key="erev_shabbat_hag", - name="Erev Shabbat/Hag", + translation_key="erev_shabbat_hag", is_on=lambda state, now: bool(state.erev_shabbat_chag(now)), entity_registry_enabled_default=False, ), JewishCalendarBinarySensorEntityDescription( key="motzei_shabbat_hag", - name="Motzei Shabbat/Hag", + translation_key="motzei_shabbat_hag", is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)), entity_registry_enabled_default=False, ), @@ -81,18 +82,9 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if sensor is on.""" - zmanim = self._get_zmanim() + zmanim = self.make_zmanim(dt.date.today()) return self.entity_description.is_on(zmanim, dt_util.now()) - def _get_zmanim(self) -> Zmanim: - """Return the Zmanim object for now().""" - return Zmanim( - date=dt.date.today(), - location=self._location, - candle_lighting_offset=self._candle_lighting_offset, - havdalah_offset=self._havdalah_offset, - ) - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -115,7 +107,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): def _schedule_update(self) -> None: """Schedule the next update of the sensor.""" now = dt_util.now() - zmanim = self._get_zmanim() + zmanim = self.make_zmanim(dt.date.today()) update = zmanim.netz_hachama.local + dt.timedelta(days=1) candle_lighting = zmanim.candle_lighting if candle_lighting is not None and now < candle_lighting < update: diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index 4572f87a113..e896bc90c9e 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -9,12 +9,7 @@ import zoneinfo from hdate.translator import Language import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_ELEVATION, CONF_LANGUAGE, @@ -44,6 +39,7 @@ from .const import ( DEFAULT_NAME, DOMAIN, ) +from .entity import JewishCalendarConfigEntry OPTIONS_SCHEMA = vol.Schema( { @@ -89,7 +85,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: JewishCalendarConfigEntry, ) -> JewishCalendarOptionsFlowHandler: """Get the options flow for this handler.""" return JewishCalendarOptionsFlowHandler() diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py index 3c5b754fee4..b3a0dea5da0 100644 --- a/homeassistant/components/jewish_calendar/const.py +++ b/homeassistant/components/jewish_calendar/const.py @@ -6,6 +6,7 @@ ATTR_AFTER_SUNSET = "after_sunset" ATTR_DATE = "date" ATTR_NUSACH = "nusach" +CONF_ALTITUDE = "altitude" # The name used by the hdate library for elevation CONF_DIASPORA = "diaspora" CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" diff --git a/homeassistant/components/jewish_calendar/diagnostics.py b/homeassistant/components/jewish_calendar/diagnostics.py new file mode 100644 index 00000000000..27415282b6d --- /dev/null +++ b/homeassistant/components/jewish_calendar/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for Jewish Calendar integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from .const import CONF_ALTITUDE +from .entity import JewishCalendarConfigEntry + +TO_REDACT = [ + CONF_ALTITUDE, + CONF_LATITUDE, + CONF_LONGITUDE, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: JewishCalendarConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": async_redact_data(asdict(entry.runtime_data), TO_REDACT), + } diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index b048b0d4bb7..b92d30048f0 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,8 +1,9 @@ """Entity representing a Jewish Calendar sensor.""" from dataclasses import dataclass +import datetime as dt -from hdate import Location +from hdate import HDateInfo, Location, Zmanim from hdate.translator import Language, set_language from homeassistant.config_entries import ConfigEntry @@ -14,6 +15,16 @@ from .const import DOMAIN type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] +@dataclass +class JewishCalendarDataResults: + """Jewish Calendar results dataclass.""" + + daytime_date: HDateInfo + after_shkia_date: HDateInfo + after_tzais_date: HDateInfo + zmanim: Zmanim + + @dataclass class JewishCalendarData: """Jewish Calendar runtime dataclass.""" @@ -23,6 +34,7 @@ class JewishCalendarData: location: Location candle_lighting_offset: int havdalah_offset: int + results: JewishCalendarDataResults | None = None class JewishCalendarEntity(Entity): @@ -42,9 +54,14 @@ class JewishCalendarEntity(Entity): entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, config_entry.entry_id)}, ) - data = config_entry.runtime_data - self._location = data.location - self._candle_lighting_offset = data.candle_lighting_offset - self._havdalah_offset = data.havdalah_offset - self._diaspora = data.diaspora - set_language(data.language) + self.data = config_entry.runtime_data + set_language(self.data.language) + + def make_zmanim(self, date: dt.date) -> Zmanim: + """Create a Zmanim object.""" + return Zmanim( + date=date, + location=self.data.location, + candle_lighting_offset=self.data.candle_lighting_offset, + havdalah_offset=self.data.havdalah_offset, + ) diff --git a/homeassistant/components/jewish_calendar/icons.json b/homeassistant/components/jewish_calendar/icons.json index 24b922df7a2..ae2f752f0f6 100644 --- a/homeassistant/components/jewish_calendar/icons.json +++ b/homeassistant/components/jewish_calendar/icons.json @@ -3,5 +3,37 @@ "count_omer": { "service": "mdi:counter" } + }, + "entity": { + "binary_sensor": { + "issur_melacha_in_effect": { "default": "mdi:power-plug-off" }, + "erev_shabbat_hag": { "default": "mdi:candle-light" }, + "motzei_shabbat_hag": { "default": "mdi:fire" } + }, + "sensor": { + "hebrew_date": { "default": "mdi:star-david" }, + "weekly_portion": { "default": "mdi:book-open-variant" }, + "holiday": { "default": "mdi:calendar-star" }, + "omer_count": { "default": "mdi:counter" }, + "daf_yomi": { "default": "mdi:book-open-variant" }, + "alot_hashachar": { "default": "mdi:weather-sunset-up" }, + "talit_and_tefillin": { "default": "mdi:calendar-clock" }, + "netz_hachama": { "default": "mdi:calendar-clock" }, + "sof_zman_shema_gra": { "default": "mdi:calendar-clock" }, + "sof_zman_shema_mga": { "default": "mdi:calendar-clock" }, + "sof_zman_tfilla_gra": { "default": "mdi:calendar-clock" }, + "sof_zman_tfilla_mga": { "default": "mdi:calendar-clock" }, + "chatzot_hayom": { "default": "mdi:calendar-clock" }, + "mincha_gedola": { "default": "mdi:calendar-clock" }, + "mincha_ketana": { "default": "mdi:calendar-clock" }, + "plag_hamincha": { "default": "mdi:weather-sunset-down" }, + "shkia": { "default": "mdi:weather-sunset" }, + "tset_hakohavim_tsom": { "default": "mdi:weather-night" }, + "tset_hakohavim_shabbat": { "default": "mdi:weather-night" }, + "upcoming_shabbat_candle_lighting": { "default": "mdi:candle" }, + "upcoming_shabbat_havdalah": { "default": "mdi:weather-night" }, + "upcoming_candle_lighting": { "default": "mdi:candle" }, + "upcoming_havdalah": { "default": "mdi:weather-night" } + } } } diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index c93844dd559..550a6514593 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.1.0"], + "requirements": ["hdate[astral]==1.1.1"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index f6c1978be21..cb38a3797eb 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import datetime as dt import logging -from typing import Any from hdate import HDateInfo, Zmanim from hdate.holidays import HolidayDatabase @@ -21,147 +22,192 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import dt as dt_util -from .entity import JewishCalendarConfigEntry, JewishCalendarEntity +from .entity import ( + JewishCalendarConfigEntry, + JewishCalendarDataResults, + JewishCalendarEntity, +) _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 -INFO_SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class JewishCalendarBaseSensorDescription(SensorEntityDescription): + """Base class describing Jewish Calendar sensor entities.""" + + value_fn: Callable | None + + +@dataclass(frozen=True, kw_only=True) +class JewishCalendarSensorDescription(JewishCalendarBaseSensorDescription): + """Class describing Jewish Calendar sensor entities.""" + + value_fn: Callable[[JewishCalendarDataResults], str | int] + attr_fn: Callable[[JewishCalendarDataResults], dict[str, str]] | None = None + options_fn: Callable[[bool], list[str]] | None = None + + +@dataclass(frozen=True, kw_only=True) +class JewishCalendarTimestampSensorDescription(JewishCalendarBaseSensorDescription): + """Class describing Jewish Calendar sensor timestamp entities.""" + + value_fn: ( + Callable[[HDateInfo, Callable[[dt.date], Zmanim]], dt.datetime | None] | None + ) = None + + +INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( + JewishCalendarSensorDescription( key="date", - name="Date", - icon="mdi:star-david", translation_key="hebrew_date", + value_fn=lambda results: str(results.after_shkia_date.hdate), + attr_fn=lambda results: { + "hebrew_year": str(results.after_shkia_date.hdate.year), + "hebrew_month_name": str(results.after_shkia_date.hdate.month), + "hebrew_day": str(results.after_shkia_date.hdate.day), + }, ), - SensorEntityDescription( + JewishCalendarSensorDescription( key="weekly_portion", - name="Parshat Hashavua", - icon="mdi:book-open-variant", + translation_key="weekly_portion", device_class=SensorDeviceClass.ENUM, + options_fn=lambda _: [str(p) for p in Parasha], + value_fn=lambda results: str(results.after_tzais_date.upcoming_shabbat.parasha), ), - SensorEntityDescription( + JewishCalendarSensorDescription( key="holiday", - name="Holiday", - icon="mdi:calendar-star", + translation_key="holiday", device_class=SensorDeviceClass.ENUM, + options_fn=lambda diaspora: HolidayDatabase(diaspora).get_all_names(), + value_fn=lambda results: ", ".join( + str(holiday) for holiday in results.after_shkia_date.holidays + ), + attr_fn=lambda results: { + "id": ", ".join( + holiday.name for holiday in results.after_shkia_date.holidays + ), + "type": ", ".join( + dict.fromkeys( + _holiday.type.name for _holiday in results.after_shkia_date.holidays + ) + ), + }, ), - SensorEntityDescription( + JewishCalendarSensorDescription( key="omer_count", - name="Day of the Omer", - icon="mdi:counter", + translation_key="omer_count", entity_registry_enabled_default=False, + value_fn=lambda results: ( + results.after_shkia_date.omer.total_days + if results.after_shkia_date.omer + else 0 + ), ), - SensorEntityDescription( + JewishCalendarSensorDescription( key="daf_yomi", - name="Daf Yomi", - icon="mdi:book-open-variant", + translation_key="daf_yomi", entity_registry_enabled_default=False, + value_fn=lambda results: str(results.daytime_date.daf_yomi), ), ) -TIME_SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( + JewishCalendarTimestampSensorDescription( key="alot_hashachar", - name="Alot Hashachar", # codespell:ignore alot - icon="mdi:weather-sunset-up", + translation_key="alot_hashachar", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="talit_and_tefillin", - name="Talit and Tefillin", - icon="mdi:calendar-clock", + translation_key="talit_and_tefillin", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="netz_hachama", - name="Hanetz Hachama", - icon="mdi:calendar-clock", + translation_key="netz_hachama", ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="sof_zman_shema_gra", - name='Latest time for Shma Gr"a', - icon="mdi:calendar-clock", + translation_key="sof_zman_shema_gra", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="sof_zman_shema_mga", - name='Latest time for Shma MG"A', - icon="mdi:calendar-clock", + translation_key="sof_zman_shema_mga", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="sof_zman_tfilla_gra", - name='Latest time for Tefilla Gr"a', - icon="mdi:calendar-clock", + translation_key="sof_zman_tfilla_gra", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="sof_zman_tfilla_mga", - name='Latest time for Tefilla MG"A', - icon="mdi:calendar-clock", + translation_key="sof_zman_tfilla_mga", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="chatzot_hayom", - name="Chatzot Hayom", - icon="mdi:calendar-clock", + translation_key="chatzot_hayom", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="mincha_gedola", - name="Mincha Gedola", - icon="mdi:calendar-clock", + translation_key="mincha_gedola", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="mincha_ketana", - name="Mincha Ketana", - icon="mdi:calendar-clock", + translation_key="mincha_ketana", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="plag_hamincha", - name="Plag Hamincha", - icon="mdi:weather-sunset-down", + translation_key="plag_hamincha", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="shkia", - name="Shkia", - icon="mdi:weather-sunset", + translation_key="shkia", ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="tset_hakohavim_tsom", - name="T'set Hakochavim", - icon="mdi:weather-night", + translation_key="tset_hakohavim_tsom", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="tset_hakohavim_shabbat", - name="T'set Hakochavim, 3 stars", - icon="mdi:weather-night", + translation_key="tset_hakohavim_shabbat", entity_registry_enabled_default=False, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="upcoming_shabbat_candle_lighting", - name="Upcoming Shabbat Candle Lighting", - icon="mdi:candle", + translation_key="upcoming_shabbat_candle_lighting", entity_registry_enabled_default=False, + value_fn=lambda at_date, mz: mz( + at_date.upcoming_shabbat.previous_day.gdate + ).candle_lighting, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="upcoming_shabbat_havdalah", - name="Upcoming Shabbat Havdalah", - icon="mdi:weather-night", + translation_key="upcoming_shabbat_havdalah", entity_registry_enabled_default=False, + value_fn=lambda at_date, mz: mz(at_date.upcoming_shabbat.gdate).havdalah, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="upcoming_candle_lighting", - name="Upcoming Candle Lighting", - icon="mdi:candle", + translation_key="upcoming_candle_lighting", + value_fn=lambda at_date, mz: mz( + at_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate + ).candle_lighting, ), - SensorEntityDescription( + JewishCalendarTimestampSensorDescription( key="upcoming_havdalah", - name="Upcoming Havdalah", - icon="mdi:weather-night", + translation_key="upcoming_havdalah", + value_fn=lambda at_date, mz: mz( + at_date.upcoming_shabbat_or_yom_tov.last_day.gdate + ).havdalah, ), ) @@ -172,40 +218,25 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Jewish calendar sensors .""" - sensors = [ + sensors: list[JewishCalendarBaseSensor] = [ JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS ] sensors.extend( JewishCalendarTimeSensor(config_entry, description) for description in TIME_SENSORS ) - - async_add_entities(sensors) + async_add_entities(sensors, update_before_add=True) -class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): - """Representation of an Jewish calendar sensor.""" +class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): + """Base class for Jewish calendar sensors.""" _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__( - self, - config_entry: JewishCalendarConfigEntry, - description: SensorEntityDescription, - ) -> None: - """Initialize the Jewish calendar sensor.""" - 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() - _LOGGER.debug("Now: %s Location: %r", now, self._location) + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) today = now.date() event_date = get_astral_event_date(self.hass, SUN_EVENT_SUNSET, today) @@ -218,7 +249,7 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): _LOGGER.debug("Now: %s Sunset: %s", now, sunset) - daytime_date = HDateInfo(today, diaspora=self._diaspora) + daytime_date = HDateInfo(today, diaspora=self.data.diaspora) # The Jewish day starts after darkness (called "tzais") and finishes at # sunset ("shkia"). The time in between is a gray area @@ -237,95 +268,57 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): if today_times.havdalah and now > today_times.havdalah: after_tzais_date = daytime_date.next_day - self._attr_native_value = self.get_state( - daytime_date, after_shkia_date, after_tzais_date - ) - _LOGGER.debug( - "New value for %s: %s", self.entity_description.key, self._attr_native_value + self.data.results = JewishCalendarDataResults( + daytime_date, after_shkia_date, after_tzais_date, today_times ) - def make_zmanim(self, date: dt.date) -> Zmanim: - """Create a Zmanim object.""" - return Zmanim( - date=date, - location=self._location, - candle_lighting_offset=self._candle_lighting_offset, - havdalah_offset=self._havdalah_offset, - ) + +class JewishCalendarSensor(JewishCalendarBaseSensor): + """Representation of an Jewish calendar sensor.""" + + entity_description: JewishCalendarSensorDescription + + def __init__( + self, + config_entry: JewishCalendarConfigEntry, + description: SensorEntityDescription, + ) -> None: + """Initialize the Jewish calendar sensor.""" + super().__init__(config_entry, description) + # Set the options for enumeration sensors + if self.entity_description.options_fn is not None: + self._attr_options = self.entity_description.options_fn(self.data.diaspora) + + @property + def native_value(self) -> str | int | dt.datetime | None: + """Return the state of the sensor.""" + if self.data.results is None: + return None + return self.entity_description.value_fn(self.data.results) @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - return self._attrs - - def get_state( - self, - daytime_date: HDateInfo, - after_shkia_date: HDateInfo, - after_tzais_date: HDateInfo, - ) -> Any | None: - """For a given type of sensor, return the state.""" - # Terminology note: by convention in py-libhdate library, "upcoming" - # refers to "current" or "upcoming" dates. - if self.entity_description.key == "date": - hdate = after_shkia_date.hdate - self._attrs = { - "hebrew_year": str(hdate.year), - "hebrew_month_name": str(hdate.month), - "hebrew_day": str(hdate.day), - } - return after_shkia_date.hdate - if self.entity_description.key == "weekly_portion": - self._attr_options = list(Parasha) - # Compute the weekly portion based on the upcoming shabbat. - return after_tzais_date.upcoming_shabbat.parasha - if self.entity_description.key == "holiday": - _holidays = after_shkia_date.holidays - _id = ", ".join(holiday.name for holiday in _holidays) - _type = ", ".join( - dict.fromkeys(_holiday.type.name for _holiday in _holidays) - ) - self._attrs = {"id": _id, "type": _type} - 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 - if self.entity_description.key == "daf_yomi": - return daytime_date.daf_yomi - - return None + if self.data.results is None: + return {} + if self.entity_description.attr_fn is not None: + return self.entity_description.attr_fn(self.data.results) + return {} -class JewishCalendarTimeSensor(JewishCalendarSensor): +class JewishCalendarTimeSensor(JewishCalendarBaseSensor): """Implement attributes for sensors returning times.""" _attr_device_class = SensorDeviceClass.TIMESTAMP + entity_description: JewishCalendarTimestampSensorDescription - def get_state( - self, - daytime_date: HDateInfo, - after_shkia_date: HDateInfo, - after_tzais_date: HDateInfo, - ) -> Any | None: - """For a given type of sensor, return the state.""" - if self.entity_description.key == "upcoming_shabbat_candle_lighting": - times = self.make_zmanim( - after_tzais_date.upcoming_shabbat.previous_day.gdate - ) - return times.candle_lighting - if self.entity_description.key == "upcoming_candle_lighting": - times = self.make_zmanim( - after_tzais_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate - ) - return times.candle_lighting - if self.entity_description.key == "upcoming_shabbat_havdalah": - times = self.make_zmanim(after_tzais_date.upcoming_shabbat.gdate) - return times.havdalah - if self.entity_description.key == "upcoming_havdalah": - times = self.make_zmanim( - after_tzais_date.upcoming_shabbat_or_yom_tov.last_day.gdate - ) - return times.havdalah - - times = self.make_zmanim(dt_util.now().date()) - return times.zmanim[self.entity_description.key].local + @property + def native_value(self) -> dt.datetime | None: + """Return the state of the sensor.""" + if self.data.results is None: + return None + if self.entity_description.value_fn is None: + return self.data.results.zmanim.zmanim[self.entity_description.key].local + return self.entity_description.value_fn( + self.data.results.after_tzais_date, self.make_zmanim + ) diff --git a/homeassistant/components/jewish_calendar/service.py b/homeassistant/components/jewish_calendar/service.py index 53d324d6efa..a065ee9c969 100644 --- a/homeassistant/components/jewish_calendar/service.py +++ b/homeassistant/components/jewish_calendar/service.py @@ -16,6 +16,7 @@ from homeassistant.core import ( ServiceResponse, SupportsResponse, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import LanguageSelector, LanguageSelectorConfig from homeassistant.helpers.sun import get_astral_event_date @@ -48,23 +49,25 @@ def async_setup_services(hass: HomeAssistant) -> None: 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") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="sunset_event" + ) 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.""" - date = call.data.get("date", dt_util.now().date()) + date = call.data.get(ATTR_DATE, dt_util.now().date()) after_sunset = ( call.data[ATTR_AFTER_SUNSET] - if "date" in call.data + if ATTR_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()] + nusach = Nusach[call.data[ATTR_NUSACH].upper()] set_language(call.data[CONF_LANGUAGE]) omer = Omer(date=hebrew_date, nusach=nusach) return { diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index dcdfb05f10c..ecfb6a472e6 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -1,28 +1,134 @@ { + "common": { + "diaspora": "Outside of Israel?", + "time_zone": "Time zone", + "descr_diaspora": "Is the location outside of Israel?", + "descr_location": "Location to use for the Jewish calendar calculations. By default, the location is set to the Home Assistant location.", + "descr_time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations", + "descr_elevation": "Elevation in meters above sea level. This is used to calculate the times correctly.", + "descr_language": "Language to use when displaying values in the UI. This does not affect the Hebrew date." + }, "entity": { + "binary_sensor": { + "issur_melacha_in_effect": { + "name": "Issur Melacha in effect" + }, + "erev_shabbat_hag": { + "name": "Erev Shabbat/Hag" + }, + "motzei_shabbat_hag": { + "name": "Motzei Shabbat/Hag" + } + }, "sensor": { "hebrew_date": { + "name": "Date", "state_attributes": { "hebrew_year": { "name": "Hebrew year" }, "hebrew_month_name": { "name": "Hebrew month name" }, "hebrew_day": { "name": "Hebrew day" } } + }, + "weekly_portion": { + "name": "Weekly Torah portion" + }, + "holiday": { + "name": "Holiday" + }, + "omer_count": { + "name": "Day of the Omer" + }, + "daf_yomi": { + "name": "Daf Yomi" + }, + "alot_hashachar": { + "name": "Halachic dawn (Alot Hashachar)" + }, + "talit_and_tefillin": { + "name": "Earliest time for Talit and Tefillin" + }, + "netz_hachama": { + "name": "Halachic sunrise (Netz Hachama)" + }, + "sof_zman_shema_gra": { + "name": "Latest time for Shma Gr\"a" + }, + "sof_zman_shema_mga": { + "name": "Latest time for Shma MG\"A" + }, + "sof_zman_tfilla_gra": { + "name": "Latest time for Tefilla Gr\"a" + }, + "sof_zman_tfilla_mga": { + "name": "Latest time for Tefilla MG\"A" + }, + "chatzot_hayom": { + "name": "Halachic midday (Chatzot Hayom)" + }, + "mincha_gedola": { + "name": "Mincha Gedola" + }, + "mincha_ketana": { + "name": "Mincha Ketana" + }, + "plag_hamincha": { + "name": "Plag Hamincha" + }, + "shkia": { + "name": "Sunset (Shkia)" + }, + "tset_hakohavim_tsom": { + "name": "Nightfall (T'set Hakochavim)" + }, + "tset_hakohavim_shabbat": { + "name": "Nightfall (T'set Hakochavim, 3 stars)" + }, + "upcoming_shabbat_candle_lighting": { + "name": "Upcoming Shabbat candle lighting" + }, + "upcoming_shabbat_havdalah": { + "name": "Upcoming Shabbat Havdalah" + }, + "upcoming_candle_lighting": { + "name": "Upcoming candle lighting" + }, + "upcoming_havdalah": { + "name": "Upcoming Havdalah" } } }, "config": { "step": { - "user": { + "reconfigure": { "data": { - "name": "[%key:common::config_flow::data::name%]", - "diaspora": "Outside of Israel?", - "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": "[%key:component::jewish_calendar::common::time_zone%]", + "diaspora": "[%key:component::jewish_calendar::common::diaspora%]", + "language": "[%key:common::config_flow::data::language%]" }, "data_description": { - "time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations" + "location": "[%key:component::jewish_calendar::common::descr_location%]", + "elevation": "[%key:component::jewish_calendar::common::descr_elevation%]", + "time_zone": "[%key:component::jewish_calendar::common::descr_time_zone%]", + "diaspora": "[%key:component::jewish_calendar::common::descr_diaspora%]", + "language": "[%key:component::jewish_calendar::common::descr_language%]" + } + }, + "user": { + "data": { + "location": "[%key:common::config_flow::data::location%]", + "elevation": "[%key:common::config_flow::data::elevation%]", + "time_zone": "[%key:component::jewish_calendar::common::time_zone%]", + "diaspora": "[%key:component::jewish_calendar::common::diaspora%]", + "language": "[%key:common::config_flow::data::language%]" + }, + "data_description": { + "location": "[%key:component::jewish_calendar::common::descr_location%]", + "elevation": "[%key:component::jewish_calendar::common::descr_elevation%]", + "time_zone": "[%key:component::jewish_calendar::common::descr_time_zone%]", + "diaspora": "[%key:component::jewish_calendar::common::descr_diaspora%]", + "language": "[%key:component::jewish_calendar::common::descr_language%]" } } }, @@ -79,5 +185,10 @@ } } } + }, + "exceptions": { + "sunset_event": { + "message": "Sunset event cannot be calculated for the provided date and location" + } } } diff --git a/homeassistant/components/kaiser_nienhaus/__init__.py b/homeassistant/components/kaiser_nienhaus/__init__.py new file mode 100644 index 00000000000..0aef3a37342 --- /dev/null +++ b/homeassistant/components/kaiser_nienhaus/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Kaiser Nienhaus.""" diff --git a/homeassistant/components/kaiser_nienhaus/manifest.json b/homeassistant/components/kaiser_nienhaus/manifest.json new file mode 100644 index 00000000000..ec52e03acd4 --- /dev/null +++ b/homeassistant/components/kaiser_nienhaus/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "kaiser_nienhaus", + "name": "Kaiser Nienhaus", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/kaleidescape/entity.py b/homeassistant/components/kaleidescape/entity.py index 667cba757d6..1c391b6600b 100644 --- a/homeassistant/components/kaleidescape/entity.py +++ b/homeassistant/components/kaleidescape/entity.py @@ -9,7 +9,7 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DOMAIN as KALEIDESCAPE_DOMAIN, NAME as KALEIDESCAPE_NAME +from .const import DOMAIN, NAME as KALEIDESCAPE_NAME if TYPE_CHECKING: from kaleidescape import Device as KaleidescapeDevice @@ -29,7 +29,7 @@ class KaleidescapeEntity(Entity): self._attr_unique_id = device.serial_number self._attr_device_info = DeviceInfo( - identifiers={(KALEIDESCAPE_DOMAIN, self._device.serial_number)}, + identifiers={(DOMAIN, self._device.serial_number)}, # Instead of setting the device name to the entity name, kaleidescape # should be updated to set has_entity_name = True name=f"{KALEIDESCAPE_NAME} {device.system.friendly_name}", diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py index 88e2e16bef2..cd8aa9d4a8e 100644 --- a/homeassistant/components/kaleidescape/media_player.py +++ b/homeassistant/components/kaleidescape/media_player.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.util.dt import utcnow -from .const import DOMAIN as KALEIDESCAPE_DOMAIN +from .const import DOMAIN from .entity import KaleidescapeEntity if TYPE_CHECKING: @@ -43,7 +43,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - entities = [KaleidescapeMediaPlayer(hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id])] + entities = [KaleidescapeMediaPlayer(hass.data[DOMAIN][entry.entry_id])] async_add_entities(entities) diff --git a/homeassistant/components/kaleidescape/remote.py b/homeassistant/components/kaleidescape/remote.py index ddafd52f220..2b341e0c429 100644 --- a/homeassistant/components/kaleidescape/remote.py +++ b/homeassistant/components/kaleidescape/remote.py @@ -9,7 +9,7 @@ from kaleidescape import const as kaleidescape_const from homeassistant.components.remote import RemoteEntity from homeassistant.exceptions import HomeAssistantError -from .const import DOMAIN as KALEIDESCAPE_DOMAIN +from .const import DOMAIN from .entity import KaleidescapeEntity if TYPE_CHECKING: @@ -27,7 +27,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - entities = [KaleidescapeRemote(hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id])] + entities = [KaleidescapeRemote(hass.data[DOMAIN][entry.entry_id])] async_add_entities(entities) diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index 8bff5df2e70..ac0f6504daa 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory -from .const import DOMAIN as KALEIDESCAPE_DOMAIN +from .const import DOMAIN from .entity import KaleidescapeEntity if TYPE_CHECKING: @@ -136,7 +136,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - device: KaleidescapeDevice = hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id] + device: KaleidescapeDevice = hass.data[DOMAIN][entry.entry_id] async_add_entities( KaleidescapeSensor(device, description) for description in SENSOR_TYPES ) diff --git a/homeassistant/components/keyboard/__init__.py b/homeassistant/components/keyboard/__init__.py index bf935f119d0..227472ff553 100644 --- a/homeassistant/components/keyboard/__init__.py +++ b/homeassistant/components/keyboard/__init__.py @@ -11,8 +11,9 @@ from homeassistant.const import ( SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType DOMAIN = "keyboard" @@ -24,6 +25,20 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Listen for keyboard events.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Keyboard", + }, + ) keyboard = PyKeyboard() keyboard.special_key_assignment() diff --git a/homeassistant/components/knocki/config_flow.py b/homeassistant/components/knocki/config_flow.py index 654dd4a4d1f..7818c752a87 100644 --- a/homeassistant/components/knocki/config_flow.py +++ b/homeassistant/components/knocki/config_flow.py @@ -10,7 +10,9 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER @@ -62,3 +64,19 @@ class KnockiConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, data_schema=DATA_SCHEMA, ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a DHCP discovery.""" + device_registry = dr.async_get(self.hass) + if device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, discovery_info.hostname)} + ): + device_registry.async_update_device( + device_entry.id, + new_connections={ + (dr.CONNECTION_NETWORK_MAC, discovery_info.macaddress) + }, + ) + return await super().async_step_dhcp(discovery_info) diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json index a91119ca831..18f25f0ab0e 100644 --- a/homeassistant/components/knocki/manifest.json +++ b/homeassistant/components/knocki/manifest.json @@ -3,6 +3,11 @@ "name": "Knocki", "codeowners": ["@joostlek", "@jgatto1", "@JakeBosh"], "config_flow": true, + "dhcp": [ + { + "hostname": "knc*" + } + ], "documentation": "https://www.home-assistant.io/integrations/knocki", "integration_type": "hub", "iot_class": "cloud_push", diff --git a/homeassistant/components/knocki/quality_scale.yaml b/homeassistant/components/knocki/quality_scale.yaml index 45b3764d786..d1c5994b277 100644 --- a/homeassistant/components/knocki/quality_scale.yaml +++ b/homeassistant/components/knocki/quality_scale.yaml @@ -50,10 +50,8 @@ rules: # Gold devices: done diagnostics: todo - discovery-update-info: - status: exempt - comment: This is a cloud service and does not benefit from device updates. - discovery: todo + discovery-update-info: done + discovery: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index eda160cd1a6..14a9016bcb9 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -84,9 +84,9 @@ CONF_KEYRING_FILE: Final = "knxkeys_file" CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE_LABELS: Final = { - CONF_KNX_TUNNELING: "UDP (Tunnelling v1)", - CONF_KNX_TUNNELING_TCP: "TCP (Tunnelling v2)", - CONF_KNX_TUNNELING_TCP_SECURE: "Secure Tunnelling (TCP)", + CONF_KNX_TUNNELING: "UDP (Tunneling v1)", + CONF_KNX_TUNNELING_TCP: "TCP (Tunneling v2)", + CONF_KNX_TUNNELING_TCP_SECURE: "Secure Tunneling (TCP)", } OPTION_MANUAL_TUNNEL: Final = "Manual" @@ -393,7 +393,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): except (vol.Invalid, XKNXException): errors[CONF_KNX_LOCAL_IP] = "invalid_ip_address" - selected_tunnelling_type = user_input[CONF_KNX_TUNNELING_TYPE] + selected_tunneling_type = user_input[CONF_KNX_TUNNELING_TYPE] if not errors: try: self._selected_tunnel = await request_description( @@ -406,16 +406,16 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): errors["base"] = "cannot_connect" else: if bool(self._selected_tunnel.tunnelling_requires_secure) is not ( - selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE + selected_tunneling_type == CONF_KNX_TUNNELING_TCP_SECURE ) or ( - selected_tunnelling_type == CONF_KNX_TUNNELING_TCP + selected_tunneling_type == CONF_KNX_TUNNELING_TCP and not self._selected_tunnel.supports_tunnelling_tcp ): errors[CONF_KNX_TUNNELING_TYPE] = "unsupported_tunnel_type" if not errors: self.new_entry_data = KNXConfigEntryData( - connection_type=selected_tunnelling_type, + connection_type=selected_tunneling_type, host=_host, port=user_input[CONF_PORT], route_back=user_input[CONF_KNX_ROUTE_BACK], @@ -426,11 +426,11 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): tunnel_endpoint_ia=None, ) - if selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE: + if selected_tunneling_type == CONF_KNX_TUNNELING_TCP_SECURE: return await self.async_step_secure_key_source_menu_tunnel() self.new_title = ( "Tunneling " - f"{'UDP' if selected_tunnelling_type == CONF_KNX_TUNNELING else 'TCP'} " + f"{'UDP' if selected_tunneling_type == CONF_KNX_TUNNELING else 'TCP'} " f"@ {_host}" ) return self.finish_flow() @@ -497,7 +497,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): async def async_step_secure_tunnel_manual( self, user_input: dict | None = None ) -> ConfigFlowResult: - """Configure ip secure tunnelling manually.""" + """Configure ip secure tunneling manually.""" errors: dict = {} if user_input is not None: diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index b403018dae3..3ce79b4ca7a 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -104,9 +104,9 @@ class KNXConfigEntryData(TypedDict, total=False): multicast_group: str multicast_port: int route_back: bool # not required - host: str # only required for tunnelling - port: int # only required for tunnelling - tunnel_endpoint_ia: str | None # tunnelling only - not required (use get()) + host: str # only required for tunneling + port: int # only required for tunneling + tunnel_endpoint_ia: str | None # tunneling only - not required (use get()) # KNX secure user_id: int | None # not required user_password: str | None # not required @@ -160,6 +160,7 @@ SUPPORTED_PLATFORMS_YAML: Final = { SUPPORTED_PLATFORMS_UI: Final = { Platform.BINARY_SENSOR, + Platform.COVER, Platform.LIGHT, Platform.SWITCH, } @@ -182,3 +183,13 @@ CURRENT_HVAC_ACTIONS: Final = { HVACMode.FAN_ONLY: HVACAction.FAN, HVACMode.DRY: HVACAction.DRYING, } + + +class CoverConf: + """Common config keys for cover.""" + + TRAVELLING_TIME_DOWN: Final = "travelling_time_down" + TRAVELLING_TIME_UP: Final = "travelling_time_up" + INVERT_UPDOWN: Final = "invert_updown" + INVERT_POSITION: Final = "invert_position" + INVERT_ANGLE: Final = "invert_angle" diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 3c5752b990c..3068e5d7ef1 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any +from typing import Any, Literal +from xknx import XKNX from xknx.devices import Cover as XknxCover from homeassistant import config_entries @@ -22,13 +22,28 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY, CoverConf +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .schema import CoverSchema +from .storage.const import ( + CONF_ENTITY, + CONF_GA_ANGLE, + CONF_GA_PASSIVE, + CONF_GA_POSITION_SET, + CONF_GA_POSITION_STATE, + CONF_GA_STATE, + CONF_GA_STEP, + CONF_GA_STOP, + CONF_GA_UP_DOWN, + CONF_GA_WRITE, +) async def async_setup_entry( @@ -36,52 +51,47 @@ async def async_setup_entry( config_entry: config_entries.ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up cover(s) for KNX platform.""" + """Set up the KNX cover platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.COVER] + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.COVER, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiCover, + ), + ) - async_add_entities(KNXCover(knx_module, entity_config) for entity_config in config) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.COVER): + entities.extend( + KnxYamlCover(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.COVER): + entities.extend( + KnxUiCover(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXCover(KnxYamlEntity, CoverEntity): +class _KnxCover(CoverEntity): """Representation of a KNX cover.""" _device: XknxCover - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize the cover.""" - super().__init__( - knx_module=knx_module, - device=XknxCover( - xknx=knx_module.xknx, - name=config[CONF_NAME], - group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS), - group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS), - group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS), - group_address_position_state=config.get( - CoverSchema.CONF_POSITION_STATE_ADDRESS - ), - group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS), - group_address_angle_state=config.get( - CoverSchema.CONF_ANGLE_STATE_ADDRESS - ), - group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS), - travel_time_down=config[CoverSchema.CONF_TRAVELLING_TIME_DOWN], - travel_time_up=config[CoverSchema.CONF_TRAVELLING_TIME_UP], - invert_updown=config[CoverSchema.CONF_INVERT_UPDOWN], - invert_position=config[CoverSchema.CONF_INVERT_POSITION], - invert_angle=config[CoverSchema.CONF_INVERT_ANGLE], - ), - ) - self._unsubscribe_auto_updater: Callable[[], None] | None = None - - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + def init_base(self) -> None: + """Initialize common attributes - may be based on xknx device instance.""" _supports_tilt = False self._attr_supported_features = ( - CoverEntityFeature.CLOSE - | CoverEntityFeature.OPEN - | CoverEntityFeature.SET_POSITION + CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN ) + if self._device.supports_position or self._device.supports_stop: + # when stop is supported, xknx travelcalculator can set position + self._attr_supported_features |= CoverEntityFeature.SET_POSITION if self._device.step.writable: _supports_tilt = True self._attr_supported_features |= ( @@ -97,13 +107,7 @@ class KNXCover(KnxYamlEntity, CoverEntity): if _supports_tilt: self._attr_supported_features |= CoverEntityFeature.STOP_TILT - self._attr_device_class = config.get(CONF_DEVICE_CLASS) or ( - CoverDeviceClass.BLIND if _supports_tilt else None - ) - self._attr_unique_id = ( - f"{self._device.updown.group_address}_" - f"{self._device.position_target.group_address}" - ) + self._attr_device_class = CoverDeviceClass.BLIND if _supports_tilt else None @property def current_cover_position(self) -> int | None: @@ -180,3 +184,102 @@ class KNXCover(KnxYamlEntity, CoverEntity): async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" await self._device.stop() + + +class KnxYamlCover(_KnxCover, KnxYamlEntity): + """Representation of a KNX cover configured from YAML.""" + + _device: XknxCover + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize the cover.""" + super().__init__( + knx_module=knx_module, + device=XknxCover( + xknx=knx_module.xknx, + name=config[CONF_NAME], + group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS), + group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS), + group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS), + group_address_position_state=config.get( + CoverSchema.CONF_POSITION_STATE_ADDRESS + ), + group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS), + group_address_angle_state=config.get( + CoverSchema.CONF_ANGLE_STATE_ADDRESS + ), + group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS), + travel_time_down=config[CoverConf.TRAVELLING_TIME_DOWN], + travel_time_up=config[CoverConf.TRAVELLING_TIME_UP], + invert_updown=config[CoverConf.INVERT_UPDOWN], + invert_position=config[CoverConf.INVERT_POSITION], + invert_angle=config[CoverConf.INVERT_ANGLE], + ), + ) + self.init_base() + + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = ( + f"{self._device.updown.group_address}_" + f"{self._device.position_target.group_address}" + ) + if custom_device_class := config.get(CONF_DEVICE_CLASS): + self._attr_device_class = custom_device_class + + +def _create_ui_cover(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxCover: + """Return a KNX Light device to be used within XKNX.""" + + def get_address( + key: str, address_type: Literal["write", "state"] = CONF_GA_WRITE + ) -> str | None: + """Get a single group address for given key.""" + return knx_config[key][address_type] if key in knx_config else None + + def get_addresses( + key: str, address_type: Literal["write", "state"] = CONF_GA_STATE + ) -> list[Any] | None: + """Get group address including passive addresses as list.""" + return ( + [knx_config[key][address_type], *knx_config[key][CONF_GA_PASSIVE]] + if key in knx_config + else None + ) + + return XknxCover( + xknx=xknx, + name=name, + group_address_long=get_addresses(CONF_GA_UP_DOWN, CONF_GA_WRITE), + group_address_short=get_addresses(CONF_GA_STEP, CONF_GA_WRITE), + group_address_stop=get_addresses(CONF_GA_STOP, CONF_GA_WRITE), + group_address_position=get_addresses(CONF_GA_POSITION_SET, CONF_GA_WRITE), + group_address_position_state=get_addresses(CONF_GA_POSITION_STATE), + group_address_angle=get_address(CONF_GA_ANGLE), + group_address_angle_state=get_addresses(CONF_GA_ANGLE), + travel_time_down=knx_config[CoverConf.TRAVELLING_TIME_DOWN], + travel_time_up=knx_config[CoverConf.TRAVELLING_TIME_UP], + invert_updown=knx_config.get(CoverConf.INVERT_UPDOWN, False), + invert_position=knx_config.get(CoverConf.INVERT_POSITION, False), + invert_angle=knx_config.get(CoverConf.INVERT_ANGLE, False), + sync_state=knx_config[CONF_SYNC_STATE], + ) + + +class KnxUiCover(_KnxCover, KnxUiEntity): + """Representation of a KNX cover configured from the UI.""" + + _device: XknxCover + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize KNX cover.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + self._device = _create_ui_cover( + knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME] + ) + self.init_base() diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index bde6dfa226f..36c4bc71273 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -10,9 +10,9 @@ "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], "requirements": [ - "xknx==3.6.0", + "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.3.8.214559" + "knx-frontend==2025.4.1.91934" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index c9fe0bfc34e..e6dc0c1bb3e 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -56,6 +56,7 @@ from .const import ( CONF_SYNC_STATE, KNX_ADDRESS, ColorTempModes, + CoverConf, FanZeroMode, ) from .validation import ( @@ -453,11 +454,6 @@ class CoverSchema(KNXPlatformSchema): CONF_POSITION_STATE_ADDRESS = "position_state_address" CONF_ANGLE_ADDRESS = "angle_address" CONF_ANGLE_STATE_ADDRESS = "angle_state_address" - CONF_TRAVELLING_TIME_DOWN = "travelling_time_down" - CONF_TRAVELLING_TIME_UP = "travelling_time_up" - CONF_INVERT_UPDOWN = "invert_updown" - CONF_INVERT_POSITION = "invert_position" - CONF_INVERT_ANGLE = "invert_angle" DEFAULT_TRAVEL_TIME = 25 DEFAULT_NAME = "KNX Cover" @@ -474,14 +470,14 @@ class CoverSchema(KNXPlatformSchema): vol.Optional(CONF_ANGLE_ADDRESS): ga_list_validator, vol.Optional(CONF_ANGLE_STATE_ADDRESS): ga_list_validator, vol.Optional( - CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME + CoverConf.TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME ): cv.positive_float, vol.Optional( - CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME + CoverConf.TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME ): cv.positive_float, - vol.Optional(CONF_INVERT_UPDOWN, default=False): cv.boolean, - vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, + vol.Optional(CoverConf.INVERT_UPDOWN, default=False): cv.boolean, + vol.Optional(CoverConf.INVERT_POSITION, default=False): cv.boolean, + vol.Optional(CoverConf.INVERT_ANGLE, default=False): cv.boolean, vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, } diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index cf3f2bb9f95..7cae0e9bbf6 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -27,3 +27,9 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness" CONF_GA_WHITE_SWITCH: Final = "ga_white_switch" CONF_GA_HUE: Final = "ga_hue" CONF_GA_SATURATION: Final = "ga_saturation" +CONF_GA_UP_DOWN: Final = "ga_up_down" +CONF_GA_STOP: Final = "ga_stop" +CONF_GA_STEP: Final = "ga_step" +CONF_GA_POSITION_SET: Final = "ga_position_set" +CONF_GA_POSITION_STATE: Final = "ga_position_state" +CONF_GA_ANGLE: Final = "ga_angle" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index cde18a181ec..85bcbd1809f 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -25,6 +25,7 @@ from ..const import ( DOMAIN, SUPPORTED_PLATFORMS_UI, ColorTempModes, + CoverConf, ) from ..validation import sync_state_validator from .const import ( @@ -33,6 +34,7 @@ from .const import ( CONF_DATA, CONF_DEVICE_INFO, CONF_ENTITY, + CONF_GA_ANGLE, CONF_GA_BLUE_BRIGHTNESS, CONF_GA_BLUE_SWITCH, CONF_GA_BRIGHTNESS, @@ -42,12 +44,17 @@ from .const import ( CONF_GA_GREEN_SWITCH, CONF_GA_HUE, CONF_GA_PASSIVE, + CONF_GA_POSITION_SET, + CONF_GA_POSITION_STATE, CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SENSOR, CONF_GA_STATE, + CONF_GA_STEP, + CONF_GA_STOP, CONF_GA_SWITCH, + CONF_GA_UP_DOWN, CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, CONF_GA_WRITE, @@ -121,15 +128,64 @@ BINARY_SENSOR_SCHEMA = vol.Schema( } ) -SWITCH_SCHEMA = vol.Schema( +COVER_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): { - vol.Optional(CONF_INVERT, default=False): bool, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Optional(CONF_RESPOND_TO_READ, default=False): bool, - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - }, + vol.Required(DOMAIN): vol.All( + vol.Schema( + { + **optional_ga_schema(CONF_GA_UP_DOWN, GASelector(state=False)), + vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), + **optional_ga_schema(CONF_GA_STOP, GASelector(state=False)), + **optional_ga_schema(CONF_GA_STEP, GASelector(state=False)), + **optional_ga_schema(CONF_GA_POSITION_SET, GASelector(state=False)), + **optional_ga_schema( + CONF_GA_POSITION_STATE, GASelector(write=False) + ), + vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), + **optional_ga_schema(CONF_GA_ANGLE, GASelector()), + vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), + vol.Optional( + CoverConf.TRAVELLING_TIME_DOWN, default=25 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=1000, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional( + CoverConf.TRAVELLING_TIME_UP, default=25 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=1000, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + }, + extra=vol.REMOVE_EXTRA, + ), + vol.Any( + vol.Schema( + { + vol.Required(CONF_GA_UP_DOWN): GASelector( + state=False, write_required=True + ) + }, + extra=vol.ALLOW_EXTRA, + ), + vol.Schema( + { + vol.Required(CONF_GA_POSITION_SET): GASelector( + state=False, write_required=True + ) + }, + extra=vol.ALLOW_EXTRA, + ), + msg=( + "At least one of 'Up/Down control' or" + " 'Position - Set position' is required." + ), + ), + ), } ) @@ -226,6 +282,19 @@ LIGHT_SCHEMA = vol.Schema( } ) + +SWITCH_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): { + vol.Optional(CONF_INVERT, default=False): bool, + vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_RESPOND_TO_READ, default=False): bool, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + }, + } +) + ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( vol.Schema( { @@ -243,11 +312,14 @@ ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( Platform.BINARY_SENSOR: vol.Schema( {vol.Required(CONF_DATA): BINARY_SENSOR_SCHEMA}, extra=vol.ALLOW_EXTRA ), - Platform.SWITCH: vol.Schema( - {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA + Platform.COVER: vol.Schema( + {vol.Required(CONF_DATA): COVER_SCHEMA}, extra=vol.ALLOW_EXTRA ), Platform.LIGHT: vol.Schema( - {vol.Required("data"): LIGHT_SCHEMA}, extra=vol.ALLOW_EXTRA + {vol.Required(CONF_DATA): LIGHT_SCHEMA}, extra=vol.ALLOW_EXTRA + ), + Platform.SWITCH: vol.Schema( + {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA ), }, ), diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index 1ac99d192b8..a1510dbb384 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -43,7 +43,20 @@ class GASelector: self._add_group_addresses(schema) self._add_passive(schema) self._add_dpt(schema) - return vol.Schema(schema) + return vol.Schema( + vol.All( + schema, + vol.Schema( # one group address shall be included + vol.Any( + {vol.Required(CONF_GA_WRITE): vol.IsTrue()}, + {vol.Required(CONF_GA_STATE): vol.IsTrue()}, + {vol.Required(CONF_GA_PASSIVE): vol.IsTrue()}, + msg="At least one group address must be set", + ), + extra=vol.ALLOW_EXTRA, + ), + ) + ) def _add_group_addresses(self, schema: dict[vol.Marker, Any]) -> None: """Add basic group address items to the schema.""" diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 737cc2d8b2d..77228ea34d9 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -85,7 +85,7 @@ } }, "secure_tunnel_manual": { - "title": "Secure tunnelling", + "title": "Secure tunneling", "description": "Please enter your IP Secure information.", "data": { "user_id": "User ID", @@ -140,7 +140,7 @@ "keyfile_not_found": "The specified `.knxkeys` file was not found in the path config/.storage/knx/", "no_router_discovered": "No KNXnet/IP router was discovered on the network.", "no_tunnel_discovered": "Could not find a KNX tunneling server on your network.", - "unsupported_tunnel_type": "Selected tunnelling type not supported by gateway." + "unsupported_tunnel_type": "Selected tunneling type not supported by gateway." } }, "options": { diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index 3f1a27302d8..d6bdab37a9c 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as KONNECTED_DOMAIN +from .const import DOMAIN async def async_setup_entry( @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors attached to a Konnected device from a config entry.""" - data = hass.data[KONNECTED_DOMAIN] + data = hass.data[DOMAIN] device_id = config_entry.data["id"] sensors = [ KonnectedBinarySensor(device_id, pin_num, pin_data) @@ -48,7 +48,7 @@ class KonnectedBinarySensor(BinarySensorEntity): self._attr_unique_id = f"{device_id}-{zone_num}" self._attr_name = data.get(CONF_NAME) self._attr_device_info = DeviceInfo( - identifiers={(KONNECTED_DOMAIN, device_id)}, + identifiers={(DOMAIN, device_id)}, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index cd36c217627..155e99a7002 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -22,7 +22,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW +from .const import DOMAIN, SIGNAL_DS18B20_NEW SENSOR_TYPES: dict[str, SensorEntityDescription] = { "temperature": SensorEntityDescription( @@ -46,7 +46,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors attached to a Konnected device from a config entry.""" - data = hass.data[KONNECTED_DOMAIN] + data = hass.data[DOMAIN] device_id = config_entry.data["id"] # Initialize all DHT sensors. @@ -121,7 +121,7 @@ class KonnectedSensor(SensorEntity): name += f" {description.name}" self._attr_name = name - self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) async def async_added_to_hass(self) -> None: """Store entity_id and register state change callback.""" diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index 58311502cbe..54f74f0d461 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -22,7 +22,7 @@ from .const import ( CONF_ACTIVATION, CONF_MOMENTARY, CONF_PAUSE, - DOMAIN as KONNECTED_DOMAIN, + DOMAIN, STATE_HIGH, STATE_LOW, ) @@ -36,7 +36,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches attached to a Konnected device from a config entry.""" - data = hass.data[KONNECTED_DOMAIN] + data = hass.data[DOMAIN] device_id = config_entry.data["id"] switches = [ KonnectedSwitch(device_id, zone_data.get(CONF_ZONE), zone_data) @@ -63,12 +63,12 @@ class KonnectedSwitch(SwitchEntity): f"{device_id}-{self._zone_num}-{self._momentary}-" f"{self._pause}-{self._repeat}" ) - self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) @property def panel(self): """Return the Konnected HTTP client.""" - device_data = self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id] + device_data = self.hass.data[DOMAIN][CONF_DEVICES][self._device_id] return device_data.get("panel") @property diff --git a/homeassistant/components/kostal_plenticore/config_flow.py b/homeassistant/components/kostal_plenticore/config_flow.py index 59c737a0874..cce220006c5 100644 --- a/homeassistant/components/kostal_plenticore/config_flow.py +++ b/homeassistant/components/kostal_plenticore/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_BASE, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import CONF_SERVICE_CODE, DOMAIN from .helper import get_hostname_id _LOGGER = logging.getLogger(__name__) @@ -21,6 +21,7 @@ DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SERVICE_CODE): str, } ) @@ -32,8 +33,10 @@ async def test_connection(hass: HomeAssistant, data) -> str: """ session = async_get_clientsession(hass) - async with ApiClient(session, data["host"]) as client: - await client.login(data["password"]) + async with ApiClient(session, data[CONF_HOST]) as client: + await client.login( + data[CONF_PASSWORD], service_code=data.get(CONF_SERVICE_CODE) + ) hostname_id = await get_hostname_id(client) values = await client.get_setting_values("scb:network", hostname_id) @@ -70,3 +73,30 @@ class KostalPlenticoreConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Add reconfigure step to allow to reconfigure a config entry.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + try: + hostname = await test_connection(self.hass, user_input) + except AuthenticationException as ex: + errors[CONF_PASSWORD] = "invalid_auth" + _LOGGER.error("Error response: %s", ex) + except (ClientError, TimeoutError): + errors[CONF_HOST] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors[CONF_BASE] = "unknown" + else: + return self.async_update_reload_and_abort( + entry=self._get_reconfigure_entry(), title=hostname, data=user_input + ) + + return self.async_show_form( + step_id="reconfigure", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index 668b10e6971..e67f9298438 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -1,3 +1,4 @@ """Constants for the Kostal Plenticore Solar Inverter integration.""" DOMAIN = "kostal_plenticore" +CONF_SERVICE_CODE = "service_code" diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index a404a997663..f87f8ca630a 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_SERVICE_CODE, DOMAIN from .helper import get_hostname_id _LOGGER = logging.getLogger(__name__) @@ -60,7 +60,10 @@ class Plenticore: async_get_clientsession(self.hass), host=self.host ) try: - await self._client.login(self.config_entry.data[CONF_PASSWORD]) + await self._client.login( + self.config_entry.data[CONF_PASSWORD], + service_code=self.config_entry.data.get(CONF_SERVICE_CODE), + ) except AuthenticationException as err: _LOGGER.error( "Authentication exception connecting to %s: %s", self.host, err diff --git a/homeassistant/components/kostal_plenticore/strings.json b/homeassistant/components/kostal_plenticore/strings.json index 30ce5af5a6c..80a6748e327 100644 --- a/homeassistant/components/kostal_plenticore/strings.json +++ b/homeassistant/components/kostal_plenticore/strings.json @@ -4,7 +4,15 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "service_code": "Service code" + } + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "service_code": "[%key:component::kostal_plenticore::config::step::user::data::service_code%]" } } }, @@ -14,7 +22,8 @@ "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%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } } } diff --git a/homeassistant/components/kraken/strings.json b/homeassistant/components/kraken/strings.json index c636dbf8d1f..d30e2bb2dff 100644 --- a/homeassistant/components/kraken/strings.json +++ b/homeassistant/components/kraken/strings.json @@ -14,7 +14,7 @@ "init": { "data": { "scan_interval": "Update interval", - "tracked_asset_pairs": "Tracked Asset Pairs" + "tracked_asset_pairs": "Tracked asset pairs" } } } @@ -40,10 +40,10 @@ "name": "Volume last 24h" }, "volume_weighted_average_today": { - "name": "Volume weighted average today" + "name": "Volume-weighted average today" }, "volume_weighted_average_last_24h": { - "name": "Volume weighted average last 24h" + "name": "Volume-weighted average last 24h" }, "number_of_trades_today": { "name": "Number of trades today" diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index c108bdb02d8..4fc2c0b05df 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -61,13 +61,16 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=( lambda machine: cast( - BackFlush, machine.dashboard.config[WidgetType.CM_BACK_FLUSH] + BackFlush, + machine.dashboard.config.get( + WidgetType.CM_BACK_FLUSH, BackFlush(status=BackFlushStatus.OFF) + ), ).status is BackFlushStatus.REQUESTED ), entity_category=EntityCategory.DIAGNOSTIC, supported_fn=lambda coordinator: ( - coordinator.device.dashboard.model_name != ModelName.GS3_MP + coordinator.device.dashboard.model_name is not ModelName.GS3_MP ), ), LaMarzoccoBinarySensorEntityDescription( diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index f0f64e02c28..b6379f237ae 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -20,8 +20,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=15) -SETTINGS_UPDATE_INTERVAL = timedelta(hours=1) -SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5) +SETTINGS_UPDATE_INTERVAL = timedelta(hours=8) +SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=30) STATISTICS_UPDATE_INTERVAL = timedelta(minutes=15) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index a319384d7fd..fb61397575d 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -82,6 +82,9 @@ "steam_boiler_ready_time": { "default": "mdi:av-timer" }, + "brewing_start_time": { + "default": "mdi:clock-start" + }, "total_coffees_made": { "default": "mdi:coffee" }, diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 44ca31427c0..46a29427264 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.4"] + "requirements": ["pylamarzocco==2.0.8"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 7c4fe33a041..980a08c09ae 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -119,7 +119,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( key="prebrew_on", translation_key="prebrew_time_on", device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, + native_unit_of_measurement=UnitOfTime.SECONDS, native_step=PRECISION_TENTHS, native_min_value=0, native_max_value=10, @@ -158,7 +158,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( key="prebrew_off", translation_key="prebrew_time_off", device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, + native_unit_of_measurement=UnitOfTime.SECONDS, native_step=PRECISION_TENTHS, native_min_value=0, native_max_value=10, diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 6e3eee50f41..29f1c6209ec 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -5,12 +5,13 @@ from dataclasses import dataclass from datetime import datetime from typing import cast -from pylamarzocco.const import ModelName, WidgetType +from pylamarzocco.const import BackFlushStatus, ModelName, WidgetType from pylamarzocco.models import ( BackFlush, BaseWidgetOutput, CoffeeAndFlushCounter, CoffeeBoiler, + MachineStatus, SteamBoilerLevel, SteamBoilerTemperature, ) @@ -72,6 +73,18 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( in (ModelName.LINEA_MICRA, ModelName.LINEA_MINI_R) ), ), + LaMarzoccoSensorEntityDescription( + key="brewing_start_time", + translation_key="brewing_start_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + MachineStatus, config[WidgetType.CM_MACHINE_STATUS] + ).brewing_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + available_fn=(lambda coordinator: not coordinator.websocket_terminated), + ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", translation_key="steam_boiler_ready_time", @@ -93,10 +106,17 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TIMESTAMP, value_fn=( lambda config: cast( - BackFlush, config[WidgetType.CM_BACK_FLUSH] + BackFlush, + config.get( + WidgetType.CM_BACK_FLUSH, BackFlush(status=BackFlushStatus.OFF) + ), ).last_cleaning_start_time ), entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + is not ModelName.GS3_MP + ), ), ) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 6383e931c22..8de62efd284 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -147,6 +147,9 @@ "steam_boiler_ready_time": { "name": "Steam boiler ready time" }, + "brewing_start_time": { + "name": "Brewing start time" + }, "total_coffees_made": { "name": "Total coffees made", "unit_of_measurement": "coffees" diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index 946c7ac3724..62a9920fb73 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -44,21 +43,6 @@ CONFIG_SCHEMA = vol.Schema(CONFIG_DATA) USER_SCHEMA = vol.Schema(USER_DATA) -def get_config_entry( - hass: HomeAssistant, data: ConfigType -) -> config_entries.ConfigEntry | None: - """Check config entries for already configured entries based on the ip address/port.""" - return next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_IP_ADDRESS] == data[CONF_IP_ADDRESS] - and entry.data[CONF_PORT] == data[CONF_PORT] - ), - None, - ) - - async def validate_connection(data: ConfigType) -> str | None: """Validate if a connection to LCN can be established.""" error = None @@ -120,19 +104,20 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="user", data_schema=USER_SCHEMA) - errors = None - if get_config_entry(self.hass, user_input): - errors = {CONF_BASE: "already_configured"} - elif (error := await validate_connection(user_input)) is not None: - errors = {CONF_BASE: error} + self._async_abort_entries_match( + { + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + CONF_PORT: user_input[CONF_PORT], + } + ) - if errors is not None: + if (error := await validate_connection(user_input)) is not None: return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( USER_SCHEMA, user_input ), - errors=errors, + errors={CONF_BASE: error}, ) data: dict = { @@ -152,15 +137,21 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: user_input[CONF_HOST] = reconfigure_entry.data[CONF_HOST] - await self.hass.config_entries.async_unload(reconfigure_entry.entry_id) - if (error := await validate_connection(user_input)) is not None: - errors = {CONF_BASE: error} + self._async_abort_entries_match( + { + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + CONF_PORT: user_input[CONF_PORT], + } + ) - if errors is None: + await self.hass.config_entries.async_unload(reconfigure_entry.entry_id) + + if (error := await validate_connection(user_input)) is None: return self.async_update_reload_and_abort( reconfigure_entry, data_updates=user_input ) + errors = {CONF_BASE: error} await self.hass.config_entries.async_setup(reconfigure_entry.entry_id) return self.async_show_form( diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index b443e05def7..d67c02ed56a 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -56,6 +56,7 @@ CONF_SCENES = "scenes" CONF_REGISTER = "register" CONF_OUTPUTS = "outputs" CONF_REVERSE_TIME = "reverse_time" +CONF_POSITIONING_MODE = "positioning_mode" DIM_MODES = ["STEPS50", "STEPS200"] @@ -235,4 +236,6 @@ TIME_UNITS = [ "D", ] -MOTOR_REVERSE_TIME = ["RT70", "RT600", "RT1200"] +MOTOR_REVERSE_TIMES = ["RT70", "RT600", "RT1200"] + +MOTOR_POSITIONING_MODES = ["NONE", "BS4", "MODULE"] diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index be713871aae..068d8f5ba11 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -6,7 +6,12 @@ from typing import Any import pypck -from homeassistant.components.cover import DOMAIN as DOMAIN_COVER, CoverEntity +from homeassistant.components.cover import ( + ATTR_POSITION, + DOMAIN as DOMAIN_COVER, + CoverEntity, + CoverEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant @@ -17,6 +22,7 @@ from .const import ( ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_MOTOR, + CONF_POSITIONING_MODE, CONF_REVERSE_TIME, DOMAIN, ) @@ -115,7 +121,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" state = pypck.lcn_defs.MotorStateModifier.DOWN - if not await self.device_connection.control_motors_outputs( + if not await self.device_connection.control_motor_outputs( state, self.reverse_time ): return @@ -126,7 +132,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" state = pypck.lcn_defs.MotorStateModifier.UP - if not await self.device_connection.control_motors_outputs( + if not await self.device_connection.control_motor_outputs( state, self.reverse_time ): return @@ -138,7 +144,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" state = pypck.lcn_defs.MotorStateModifier.STOP - if not await self.device_connection.control_motors_outputs(state): + if not await self.device_connection.control_motor_outputs(state): return self._attr_is_closing = False self._attr_is_opening = False @@ -176,11 +182,25 @@ class LcnRelayCover(LcnEntity, CoverEntity): _attr_is_closing = False _attr_is_opening = False _attr_assumed_state = True + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + positioning_mode: pypck.lcn_defs.MotorPositioningMode def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN cover.""" super().__init__(config, config_entry) + self.positioning_mode = pypck.lcn_defs.MotorPositioningMode( + config[CONF_DOMAIN_DATA].get( + CONF_POSITIONING_MODE, pypck.lcn_defs.MotorPositioningMode.NONE.value + ) + ) + + if self.positioning_mode != pypck.lcn_defs.MotorPositioningMode.NONE: + self._attr_supported_features |= CoverEntityFeature.SET_POSITION + self.motor = pypck.lcn_defs.MotorPort[config[CONF_DOMAIN_DATA][CONF_MOTOR]] self.motor_port_onoff = self.motor.value * 2 self.motor_port_updown = self.motor_port_onoff + 1 @@ -193,7 +213,9 @@ class LcnRelayCover(LcnEntity, CoverEntity): """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.motor) + await self.device_connection.activate_status_request_handler( + self.motor, self.positioning_mode + ) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" @@ -203,9 +225,11 @@ class LcnRelayCover(LcnEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 - states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.DOWN - if not await self.device_connection.control_motors_relays(states): + if not await self.device_connection.control_motor_relays( + self.motor.value, + pypck.lcn_defs.MotorStateModifier.DOWN, + self.positioning_mode, + ): return self._attr_is_opening = False self._attr_is_closing = True @@ -213,9 +237,11 @@ class LcnRelayCover(LcnEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 - states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.UP - if not await self.device_connection.control_motors_relays(states): + if not await self.device_connection.control_motor_relays( + self.motor.value, + pypck.lcn_defs.MotorStateModifier.UP, + self.positioning_mode, + ): return self._attr_is_closed = False self._attr_is_opening = True @@ -224,26 +250,55 @@ class LcnRelayCover(LcnEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 - states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.STOP - if not await self.device_connection.control_motors_relays(states): + if not await self.device_connection.control_motor_relays( + self.motor.value, + pypck.lcn_defs.MotorStateModifier.STOP, + self.positioning_mode, + ): return self._attr_is_closing = False self._attr_is_opening = False self.async_write_ha_state() - def input_received(self, input_obj: InputType) -> None: - """Set cover states when LCN input object (command) is received.""" - if not isinstance(input_obj, pypck.inputs.ModStatusRelays): + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + if not await self.device_connection.control_motor_relays_position( + self.motor.value, position, mode=self.positioning_mode + ): return - - states = input_obj.states # list of boolean values (relay on/off) - if states[self.motor_port_onoff]: # motor is on - self._attr_is_opening = not states[self.motor_port_updown] # set direction - self._attr_is_closing = states[self.motor_port_updown] # set direction - else: # motor is off - self._attr_is_opening = False - self._attr_is_closing = False - self._attr_is_closed = states[self.motor_port_updown] + self._attr_is_closed = (self._attr_current_cover_position == 0) & ( + position == 0 + ) + if self._attr_current_cover_position is not None: + self._attr_is_closing = self._attr_current_cover_position > position + self._attr_is_opening = self._attr_current_cover_position < position + self._attr_current_cover_position = position self.async_write_ha_state() + + def input_received(self, input_obj: InputType) -> None: + """Set cover states when LCN input object (command) is received.""" + if isinstance(input_obj, pypck.inputs.ModStatusRelays): + self._attr_is_opening = input_obj.is_opening(self.motor.value) + self._attr_is_closing = input_obj.is_closing(self.motor.value) + + if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.NONE: + self._attr_is_closed = input_obj.is_assumed_closed(self.motor.value) + self.async_write_ha_state() + elif ( + isinstance( + input_obj, + ( + pypck.inputs.ModStatusMotorPositionBS4, + pypck.inputs.ModStatusMotorPositionModule, + ), + ) + and input_obj.motor == self.motor.value + ): + self._attr_current_cover_position = input_obj.position + if self._attr_current_cover_position in [0, 100]: + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed = self._attr_current_cover_position == 0 + self.async_write_ha_state() diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index 24897287449..a1940fc7ac3 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -23,6 +23,7 @@ class LcnEntity(Entity): """Parent class for all entities associated with the LCN component.""" _attr_should_poll = False + _attr_has_entity_name = True device_connection: DeviceConnectionType def __init__( diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index a2796f88368..1bc4c6caa41 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -283,26 +283,6 @@ def get_device_config( return None -def is_address(value: str) -> tuple[AddressType, str]: - """Validate the given address string. - - Examples for S000M005 at myhome: - myhome.s000.m005 - myhome.s0.m5 - myhome.0.5 ("m" is implicit if missing) - - Examples for s000g011 - myhome.0.g11 - myhome.s0.g11 - """ - if matcher := PATTERN_ADDRESS.match(value): - is_group = matcher.group("type") == "g" - addr = (int(matcher.group("seg_id")), int(matcher.group("id")), is_group) - conn_id = matcher.group("conn_id") - return addr, conn_id - raise ValueError(f"{value} is not a valid address string") - - def is_states_string(states_string: str) -> list[str]: """Validate the given states string and return states list.""" if len(states_string) != 8: diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index e5313eee4f3..be5d6299f09 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.4"] + "requirements": ["pypck==0.8.6", "lcn-frontend==0.2.5"] } diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index d90e264692c..fcc6044dd77 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -21,6 +21,7 @@ from .const import ( CONF_MOTOR, CONF_OUTPUT, CONF_OUTPUTS, + CONF_POSITIONING_MODE, CONF_REGISTER, CONF_REVERSE_TIME, CONF_SETPOINT, @@ -30,7 +31,8 @@ from .const import ( LED_PORTS, LOGICOP_PORTS, MOTOR_PORTS, - MOTOR_REVERSE_TIME, + MOTOR_POSITIONING_MODES, + MOTOR_REVERSE_TIMES, OUTPUT_PORTS, RELAY_PORTS, S0_INPUTS, @@ -68,8 +70,11 @@ DOMAIN_DATA_CLIMATE: VolDictType = { DOMAIN_DATA_COVER: VolDictType = { vol.Required(CONF_MOTOR): vol.All(vol.Upper, vol.In(MOTOR_PORTS)), + vol.Optional(CONF_POSITIONING_MODE, default="none"): vol.All( + vol.Upper, vol.In(MOTOR_POSITIONING_MODES) + ), vol.Optional(CONF_REVERSE_TIME, default="rt1200"): vol.All( - vol.Upper, vol.In(MOTOR_REVERSE_TIME) + vol.Upper, vol.In(MOTOR_REVERSE_TIMES) ), } diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 2694bed31d2..fdc5359d300 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -6,10 +6,8 @@ import pypck import voluptuous as vol from homeassistant.const import ( - CONF_ADDRESS, CONF_BRIGHTNESS, CONF_DEVICE_ID, - CONF_HOST, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, ) @@ -21,7 +19,6 @@ from homeassistant.core import ( ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import ( CONF_KEYS, @@ -51,12 +48,7 @@ from .const import ( VAR_UNITS, VARIABLES, ) -from .helpers import ( - DeviceConnectionType, - get_device_connection, - is_address, - is_states_string, -) +from .helpers import DeviceConnectionType, is_states_string class LcnServiceCall: @@ -64,8 +56,7 @@ class LcnServiceCall: schema = vol.Schema( { - vol.Optional(CONF_DEVICE_ID): cv.string, - vol.Optional(CONF_ADDRESS): is_address, + vol.Required(CONF_DEVICE_ID): cv.string, } ) supports_response = SupportsResponse.NONE @@ -76,46 +67,18 @@ class LcnServiceCall: def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType: """Get address connection object.""" - if CONF_DEVICE_ID not in service.data and CONF_ADDRESS not in service.data: + device_id = service.data[CONF_DEVICE_ID] + device_registry = dr.async_get(self.hass) + if not (device := device_registry.async_get(device_id)): raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_device_identifier", + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, ) - if CONF_DEVICE_ID in service.data: - device_id = service.data[CONF_DEVICE_ID] - device_registry = dr.async_get(self.hass) - if not (device := device_registry.async_get(device_id)): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_device_id", - translation_placeholders={"device_id": device_id}, - ) - - return self.hass.data[DOMAIN][device.primary_config_entry][ - DEVICE_CONNECTIONS - ][device_id] - - async_create_issue( - self.hass, - DOMAIN, - "deprecated_address_parameter", - breaks_in_ha_version="2025.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_address_parameter", - ) - - address, host_name = service.data[CONF_ADDRESS] - for config_entry in self.hass.config_entries.async_entries(DOMAIN): - if config_entry.data[CONF_HOST] == host_name: - device_connection = get_device_connection( - self.hass, address, config_entry - ) - if device_connection is None: - raise ValueError("Wrong address.") - return device_connection - raise ValueError("Invalid host name.") + return self.hass.data[DOMAIN][device.primary_config_entry][DEVICE_CONNECTIONS][ + device_id + ] async def async_call_service(self, service: ServiceCall) -> ServiceResponse: """Execute service call.""" diff --git a/homeassistant/components/lcn/services.yaml b/homeassistant/components/lcn/services.yaml index f58e79b9f40..ad0e7dfec86 100644 --- a/homeassistant/components/lcn/services.yaml +++ b/homeassistant/components/lcn/services.yaml @@ -2,9 +2,10 @@ output_abs: fields: - device_id: + device_id: &device_id + required: true example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: &device_selector + selector: device: filter: - integration: lcn @@ -71,10 +72,6 @@ output_abs: model: LCN-UMF - integration: lcn model: LCN-WBH - address: - example: "myhome.s0.m7" - selector: - text: output: required: true selector: @@ -102,13 +99,7 @@ output_abs: output_rel: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id output: required: true selector: @@ -128,13 +119,7 @@ output_rel: output_toggle: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id output: required: true selector: @@ -155,13 +140,7 @@ output_toggle: relays: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id state: required: true example: "t---001-" @@ -170,13 +149,7 @@ relays: led: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id led: required: true selector: @@ -206,13 +179,7 @@ led: var_abs: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id variable: required: true default: native @@ -275,13 +242,7 @@ var_abs: var_reset: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id variable: required: true selector: @@ -310,13 +271,7 @@ var_reset: var_rel: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id variable: required: true selector: @@ -403,13 +358,7 @@ var_rel: lock_regulator: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id setpoint: required: true selector: @@ -439,13 +388,7 @@ lock_regulator: send_keys: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id keys: required: true example: "a1a5d8" @@ -488,13 +431,7 @@ send_keys: lock_keys: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id table: example: "a" default: a @@ -533,13 +470,7 @@ lock_keys: dyn_text: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id row: required: true selector: @@ -554,13 +485,7 @@ dyn_text: pck: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id pck: required: true example: "PIN4" diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 0a8112d997a..9d806bce104 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -66,11 +66,11 @@ "error": { "authentication_error": "Authentication failed. Wrong username or password.", "license_error": "Maximum number of connections was reached. An additional licence key is required.", - "connection_refused": "Unable to connect to PCHK. Check IP and port.", - "already_configured": "PCHK connection using the same ip address/port is already configured." + "connection_refused": "Unable to connect to PCHK. Check IP and port." }, "abort": { - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "already_configured": "PCHK connection using the same ip address/port is already configured." } }, "issues": { @@ -81,10 +81,6 @@ "deprecated_keylock_sensor": { "title": "Deprecated LCN key lock binary sensor", "description": "Your LCN key lock binary sensor entity `{entity}` is being used in automations or scripts. A key lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." - }, - "deprecated_address_parameter": { - "title": "Deprecated 'address' parameter", - "description": "The 'address' parameter in the LCN action calls is deprecated. The 'device ID' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." } }, "services": { @@ -418,9 +414,6 @@ } }, "exceptions": { - "no_device_identifier": { - "message": "No device identifier provided. Please provide the device ID." - }, "invalid_address": { "message": "LCN device for given address has not been configured." }, diff --git a/homeassistant/components/lektrico/button.py b/homeassistant/components/lektrico/button.py index e598773321d..95913b33700 100644 --- a/homeassistant/components/lektrico/button.py +++ b/homeassistant/components/lektrico/button.py @@ -39,6 +39,12 @@ BUTTONS_FOR_CHARGERS: tuple[LektricoButtonEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, press_fn=lambda device: device.send_charge_stop(), ), + LektricoButtonEntityDescription( + key="charging_schedule_override", + translation_key="charging_schedule_override", + entity_category=EntityCategory.CONFIG, + press_fn=lambda device: device.send_charge_schedule_override(), + ), LektricoButtonEntityDescription( key="reboot", device_class=ButtonDeviceClass.RESTART, diff --git a/homeassistant/components/lektrico/manifest.json b/homeassistant/components/lektrico/manifest.json index d34915d66ba..1924f0a1fc8 100644 --- a/homeassistant/components/lektrico/manifest.json +++ b/homeassistant/components/lektrico/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/lektrico", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["lektricowifi==0.0.43"], + "requirements": ["lektricowifi==0.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index 23aac0b3059..6664dd9672d 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -60,6 +60,9 @@ }, "charge_stop": { "name": "Charge stop" + }, + "charging_schedule_override": { + "name": "Charging schedule override" } }, "number": { diff --git a/homeassistant/components/lg_thinq/fan.py b/homeassistant/components/lg_thinq/fan.py index 6d07c98744a..7d20be68b01 100644 --- a/homeassistant/components/lg_thinq/fan.py +++ b/homeassistant/components/lg_thinq/fan.py @@ -2,11 +2,13 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any from thinqconnect import DeviceType -from thinqconnect.integration import ExtendedProperty +from thinqconnect.devices.const import Property as ThinQProperty +from thinqconnect.integration import ActiveMode from homeassistant.components.fan import ( FanEntity, @@ -24,16 +26,35 @@ from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator from .entity import ThinQEntity -DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[FanEntityDescription, ...]] = { + +@dataclass(frozen=True, kw_only=True) +class ThinQFanEntityDescription(FanEntityDescription): + """Describes ThinQ fan entity.""" + + operation_key: str + preset_modes: list[str] | None = None + + +DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[ThinQFanEntityDescription, ...]] = { DeviceType.CEILING_FAN: ( - FanEntityDescription( - key=ExtendedProperty.FAN, + ThinQFanEntityDescription( + key=ThinQProperty.WIND_STRENGTH, name=None, + operation_key=ThinQProperty.CEILING_FAN_OPERATION_MODE, + ), + ), + DeviceType.VENTILATOR: ( + ThinQFanEntityDescription( + key=ThinQProperty.WIND_STRENGTH, + name=None, + translation_key=ThinQProperty.WIND_STRENGTH, + operation_key=ThinQProperty.VENTILATOR_OPERATION_MODE, + preset_modes=["auto"], ), ), } -FOUR_STEP_SPEEDS = ["low", "mid", "high", "turbo"] +ORDERED_NAMED_FAN_SPEEDS = ["low", "mid", "high", "turbo", "power"] _LOGGER = logging.getLogger(__name__) @@ -52,7 +73,9 @@ async def async_setup_entry( for description in descriptions: entities.extend( ThinQFanEntity(coordinator, description, property_id) - for property_id in coordinator.api.get_active_idx(description.key) + for property_id in coordinator.api.get_active_idx( + description.key, ActiveMode.READ_WRITE + ) ) if entities: @@ -65,48 +88,76 @@ class ThinQFanEntity(ThinQEntity, FanEntity): def __init__( self, coordinator: DeviceDataUpdateCoordinator, - entity_description: FanEntityDescription, + entity_description: ThinQFanEntityDescription, property_id: str, ) -> None: """Initialize fan platform.""" super().__init__(coordinator, entity_description, property_id) - self._ordered_named_fan_speeds = [] + self._ordered_named_fan_speeds = ORDERED_NAMED_FAN_SPEEDS.copy() self._attr_supported_features = ( FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) - if (fan_modes := self.data.fan_modes) is not None: - self._attr_speed_count = len(fan_modes) - if self.speed_count == 4: - self._ordered_named_fan_speeds = FOUR_STEP_SPEEDS + self._attr_preset_modes = [] + for option in self.data.options: + if ( + entity_description.preset_modes is not None + and option in entity_description.preset_modes + ): + self._attr_supported_features |= FanEntityFeature.PRESET_MODE + self._attr_preset_modes.append(option) + else: + for ordered_step in ORDERED_NAMED_FAN_SPEEDS: + if ( + ordered_step in self._ordered_named_fan_speeds + and ordered_step not in self.data.options + ): + self._ordered_named_fan_speeds.remove(ordered_step) + self._attr_speed_count = len(self._ordered_named_fan_speeds) + self._operation_id = entity_description.operation_key def _update_status(self) -> None: """Update status itself.""" super()._update_status() # Update power on state. - self._attr_is_on = self.data.is_on + self._attr_is_on = _is_on = self.coordinator.data[self._operation_id].is_on # Update fan speed. - if ( - self.data.is_on - and (mode := self.data.fan_mode) in self._ordered_named_fan_speeds - ): - self._attr_percentage = ordered_list_item_to_percentage( - self._ordered_named_fan_speeds, mode - ) + if _is_on and (mode := self.data.value) is not None: + if self.preset_modes is not None and mode in self.preset_modes: + self._attr_preset_mode = mode + self._attr_percentage = 0 + elif mode in self._ordered_named_fan_speeds: + self._attr_percentage = ordered_list_item_to_percentage( + self._ordered_named_fan_speeds, mode + ) + self._attr_preset_mode = None else: + self._attr_preset_mode = None self._attr_percentage = 0 _LOGGER.debug( - "[%s:%s] update status: %s -> %s (percentage=%s)", + "[%s:%s] update status: is_on=%s, percentage=%s, preset_mode=%s", self.coordinator.device_name, self.property_id, - self.data.is_on, - self.is_on, + _is_on, self.percentage, + self.preset_mode, + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + _LOGGER.debug( + "[%s:%s] async_set_preset_mode. preset_mode=%s", + self.coordinator.device_name, + self.property_id, + preset_mode, + ) + await self.async_call_api( + self.coordinator.api.post(self.property_id, preset_mode) ) async def async_set_percentage(self, percentage: int) -> None: @@ -129,9 +180,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity): percentage, value, ) - await self.async_call_api( - self.coordinator.api.async_set_fan_mode(self.property_id, value) - ) + await self.async_call_api(self.coordinator.api.post(self.property_id, value)) async def async_turn_on( self, @@ -141,13 +190,25 @@ class ThinQFanEntity(ThinQEntity, FanEntity): ) -> None: """Turn on the fan.""" _LOGGER.debug( - "[%s:%s] async_turn_on", self.coordinator.device_name, self.property_id + "[%s:%s] async_turn_on percentage=%s, preset_mode=%s, kwargs=%s", + self.coordinator.device_name, + self._operation_id, + percentage, + preset_mode, + kwargs, + ) + await self.async_call_api( + self.coordinator.api.async_turn_on(self._operation_id) ) - await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id)) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" _LOGGER.debug( - "[%s:%s] async_turn_off", self.coordinator.device_name, self.property_id + "[%s:%s] async_turn_off kwargs=%s", + self.coordinator.device_name, + self._operation_id, + kwargs, + ) + await self.async_call_api( + self.coordinator.api.async_turn_off(self._operation_id) ) - await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id)) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 3b0baaaaf75..02af1dec155 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -166,6 +166,9 @@ "monitoring_enabled": { "default": "mdi:monitor-eye" }, + "current_job_mode_ventilator": { + "default": "mdi:format-list-bulleted" + }, "current_job_mode": { "default": "mdi:format-list-bulleted" }, diff --git a/homeassistant/components/lg_thinq/select.py b/homeassistant/components/lg_thinq/select.py index 3f29ee9e5c8..80dcc4a40da 100644 --- a/homeassistant/components/lg_thinq/select.py +++ b/homeassistant/components/lg_thinq/select.py @@ -121,6 +121,12 @@ DEVICE_TYPE_SELECT_MAP: dict[DeviceType, tuple[SelectEntityDescription, ...]] = ), DeviceType.REFRIGERATOR: (SELECT_DESC[ThinQProperty.FRESH_AIR_FILTER],), DeviceType.STYLER: (OPERATION_SELECT_DESC[ThinQProperty.STYLER_OPERATION_MODE],), + DeviceType.VENTILATOR: ( + SelectEntityDescription( + key=ThinQProperty.CURRENT_JOB_MODE, + translation_key="current_job_mode_ventilator", + ), + ), DeviceType.WASHCOMBO_MAIN: ( OPERATION_SELECT_DESC[ThinQProperty.WASHER_OPERATION_MODE], ), diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index a5fb81e3818..38ea7b454ae 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -901,6 +901,14 @@ "always": "[%key:component::lg_thinq::entity::sensor::monitoring_enabled::state::always%]" } }, + "current_job_mode_ventilator": { + "name": "Operating mode", + "state": { + "vent_auto": "[%key:common::state::auto%]", + "vent_nature": "Bypass", + "vent_heat_exchange": "Heat exchange" + } + }, "current_job_mode": { "name": "Operating mode", "state": { diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 18b9457ebf4..b93714a2cdf 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -32,6 +32,7 @@ "LIFX GU10", "LIFX Indoor Neon", "LIFX Lightstrip", + "LIFX Luna", "LIFX Mini", "LIFX Neon", "LIFX Nightvision", @@ -51,7 +52,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.1.4", + "aiolifx==1.1.5", "aiolifx-effects==0.3.2", "aiolifx-themes==0.6.4" ] diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py index 11e4aabf257..266d2fef857 100644 --- a/homeassistant/components/linkplay/config_flow.py +++ b/homeassistant/components/linkplay/config_flow.py @@ -31,6 +31,9 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle Zeroconf discovery.""" + # Do not probe the device if the host is already configured + self._async_abort_entries_match({CONF_HOST: discovery_info.host}) + session: ClientSession = await async_get_client_session(self.hass) bridge: LinkPlayBridge | None = None diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index ac89d2ff399..d6319c7a506 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.5"], + "requirements": ["python-linkplay==0.2.11"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index f5b26743a03..6b8e0d08d52 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -7,8 +7,9 @@ import time import lirc from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -26,6 +27,20 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIRC capability.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LIRC", + }, + ) # blocking=True gives unexpected behavior (multiple responses for 1 press) # also by not blocking, we allow hass to shut down the thread gracefully # on exit. diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 9e9cc8f0740..4117069aa0e 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -17,7 +17,7 @@ from .coordinator import LitterRobotDataUpdateCoordinator _WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet) -def get_device_info(whisker_entity: _WhiskerEntityT) -> DeviceInfo: +def get_device_info(whisker_entity: Robot | Pet) -> DeviceInfo: """Get device info for a robot or pet.""" if isinstance(whisker_entity, Robot): return DeviceInfo( diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 639cf5234d1..8534cc1bfbf 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -36,6 +36,11 @@ _LOGGER = logging.getLogger(__name__) PRODID = "-//homeassistant.io//local_calendar 1.0//EN" +# The calendar on disk is only changed when this entity is updated, so there +# is no need to poll for changes. The calendar enttiy base class will handle +# refreshing the entity state based on the start or end time of the event. +SCAN_INTERVAL = timedelta(days=1) + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 07de4a82244..e0b08313d63 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.2.4"] + "requirements": ["ical==10.0.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 367c75d5755..c8e80e4f91b 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.2.4"] + "requirements": ["ical==10.0.0"] } diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index f7ae9039729..9663efdd76e 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as LT_DOMAIN, TRACKER_UPDATE +from . import DOMAIN, TRACKER_UPDATE async def async_setup_entry( @@ -19,14 +19,14 @@ async def async_setup_entry( @callback def _receive_data(device, location, location_name): """Receive set location.""" - if device in hass.data[LT_DOMAIN]["devices"]: + if device in hass.data[DOMAIN]["devices"]: return - hass.data[LT_DOMAIN]["devices"].add(device) + hass.data[DOMAIN]["devices"].add(device) async_add_entities([LocativeEntity(device, location, location_name)]) - hass.data[LT_DOMAIN]["unsub_device_tracker"][entry.entry_id] = ( + hass.data[DOMAIN]["unsub_device_tracker"][entry.entry_id] = ( async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) ) diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index cb0f0da5227..4a92eb5c3b7 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as CASETA_DOMAIN +from . import DOMAIN from .const import CONFIG_URL, MANUFACTURER, UNASSIGNED_AREA from .entity import LutronCasetaEntity from .models import LutronCasetaConfigEntry @@ -49,11 +49,11 @@ class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity): name = f"{area} {device['device_name']}" self._attr_name = name self._attr_device_info = DeviceInfo( - identifiers={(CASETA_DOMAIN, self.unique_id)}, + identifiers={(DOMAIN, self.unique_id)}, manufacturer=MANUFACTURER, model="Lutron Occupancy", name=self.name, - via_device=(CASETA_DOMAIN, self._bridge_device["serial"]), + via_device=(DOMAIN, self._bridge_device["serial"]), configuration_url=CONFIG_URL, entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py index 671df82d8e0..4838064eaaf 100644 --- a/homeassistant/components/lutron_caseta/scene.py +++ b/homeassistant/components/lutron_caseta/scene.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as CASETA_DOMAIN +from .const import DOMAIN from .util import serial_to_unique_id @@ -39,7 +39,7 @@ class LutronCasetaScene(Scene): self._bridge: Smartbridge = data.bridge bridge_unique_id = serial_to_unique_id(data.bridge_device["serial"]) self._attr_device_info = DeviceInfo( - identifiers={(CASETA_DOMAIN, data.bridge_device["serial"])}, + identifiers={(DOMAIN, data.bridge_device["serial"])}, ) self._attr_name = scene["name"] self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}" diff --git a/homeassistant/components/lyric/application_credentials.py b/homeassistant/components/lyric/application_credentials.py index 2ccdca72bb6..9c53395bb6d 100644 --- a/homeassistant/components/lyric/application_credentials.py +++ b/homeassistant/components/lyric/application_credentials.py @@ -24,3 +24,11 @@ async def async_get_auth_implementation( token_url=OAUTH2_TOKEN, ), ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "developer_dashboard_url": "https://developer.honeywellhome.com", + "redirect_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index bc48a791e70..786f49e5300 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "To be able to log in to Honeywell Lyric the integration requires a client ID and secret. To acquire those, please follow the following steps.\n\n1. Go to the [Honeywell Lyric Developer Apps Dashboard]({developer_dashboard_url}).\n1. Sign up for a developer account if you don't have one yet. This is a separate account from your Honeywell account.\n1. Log in with your Honeywell Lyric developer account.\n1. Go to the **My Apps** section.\n1. Press the **CREATE NEW APP** button.\n1. Give the application a name of your choice.\n1. Set the **Callback URL** to `{redirect_url}`.\n1. Save your changes.\\n1. Copy the **Consumer Key** and paste it here as the **Client ID**, then copy the **Consumer Secret** and paste it here as the **Client Secret**." + }, "config": { "step": { "pick_implementation": { @@ -7,6 +10,9 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Lyric integration needs to re-authenticate your account." + }, + "oauth_discovery": { + "description": "Home Assistant has found a Honeywell Lyric device on your network. Be aware that the setup of the Lyric integration is more complicated than other integrations. Press **Submit** to continue setting up Honeywell Lyric." } }, "abort": { diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py index 26ff13f2a6f..b839e184810 100644 --- a/homeassistant/components/mailgun/notify.py +++ b/homeassistant/components/mailgun/notify.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_RECIPIENT, CONF_ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_SANDBOX, DOMAIN as MAILGUN_DOMAIN +from . import CONF_SANDBOX, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> MailgunNotificationService | None: """Get the Mailgun notification service.""" - data = hass.data[MAILGUN_DOMAIN] + data = hass.data[DOMAIN] mailgun_service = MailgunNotificationService( data.get(CONF_DOMAIN), data.get(CONF_SANDBOX), diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 95375d5fc49..2d04a936ee5 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -334,4 +334,47 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterBinarySensor, required_attributes=(clusters.WaterHeaterManagement.Attributes.BoostState,), ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="PumpFault", + translation_key="pump_fault", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + # DeviceFault or SupplyFault bit enabled + measurement_to_ha={ + clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True, + clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True, + clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False, + clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedHigh: False, + clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kLocalOverride: False, + clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning: False, + clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemotePressure: False, + clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteFlow: False, + clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteTemperature: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=( + clusters.PumpConfigurationAndControl.Attributes.PumpStatus, + ), + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="PumpStatusRunning", + translation_key="pump_running", + device_class=BinarySensorDeviceClass.RUNNING, + measurement_to_ha=lambda x: ( + x + == clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning + ), + ), + entity_class=MatterBinarySensor, + required_attributes=( + clusters.PumpConfigurationAndControl.Attributes.PumpStatus, + ), + allow_multi=True, + ), ] diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 82e45e0383a..ac3e70dcfc8 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -57,6 +57,9 @@ "bat_replacement_description": { "default": "mdi:battery-sync" }, + "flow": { + "default": "mdi:pipe" + }, "hepa_filter_condition": { "default": "mdi:filter-check" }, @@ -86,6 +89,15 @@ }, "evse_fault_state": { "default": "mdi:ev-station" + }, + "pump_control_mode": { + "default": "mdi:pipe-wrench" + }, + "pump_speed": { + "default": "mdi:speedometer" + }, + "pump_status": { + "default": "mdi:pump" } }, "switch": { diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 2c7a9651c60..4b469fa85e4 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -183,4 +183,34 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4874,), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="PIROccupiedToUnoccupiedDelay", + entity_category=EntityCategory.CONFIG, + translation_key="pir_occupied_to_unoccupied_delay", + native_max_value=65534, + native_min_value=0, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + clusters.OccupancySensing.Attributes.PIROccupiedToUnoccupiedDelay, + ), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="AutoRelockTimer", + entity_category=EntityCategory.CONFIG, + translation_key="auto_relock_timer", + native_max_value=65534, + native_min_value=0, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=(clusters.DoorLock.Attributes.AutoRelockTime,), + ), ] diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 6e77be93705..ac1bc2d1f8f 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -30,6 +30,13 @@ NUMBER_OF_RINSES_STATE_MAP = { NUMBER_OF_RINSES_STATE_MAP_REVERSE = { v: k for k, v in NUMBER_OF_RINSES_STATE_MAP.items() } +PUMP_OPERATION_MODE_MAP = { + clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kNormal: "normal", + clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kMinimum: "minimum", + clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kMaximum: "maximum", + clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kLocal: "local", +} +PUMP_OPERATION_MODE_MAP_REVERSE = {v: k for k, v in PUMP_OPERATION_MODE_MAP.items()} type SelectCluster = ( clusters.ModeSelect @@ -436,4 +443,41 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported rinses list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterSelectEntityDescription( + key="DoorLockSoundVolume", + entity_category=EntityCategory.CONFIG, + translation_key="door_lock_sound_volume", + options=["silent", "low", "medium", "high"], + measurement_to_ha={ + 0: "silent", + 1: "low", + 3: "medium", + 2: "high", + }.get, + ha_to_native_value={ + "silent": 0, + "low": 1, + "medium": 3, + "high": 2, + }.get, + ), + entity_class=MatterAttributeSelectEntity, + required_attributes=(clusters.DoorLock.Attributes.SoundVolume,), + ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterSelectEntityDescription( + key="PumpConfigurationAndControlOperationMode", + translation_key="pump_operation_mode", + options=list(PUMP_OPERATION_MODE_MAP.values()), + measurement_to_ha=PUMP_OPERATION_MODE_MAP.get, + ha_to_native_value=PUMP_OPERATION_MODE_MAP_REVERSE.get, + ), + entity_class=MatterAttributeSelectEntity, + required_attributes=( + clusters.PumpConfigurationAndControl.Attributes.OperationMode, + ), + ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index f1704b45c50..70e4cb238f5 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -29,6 +29,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, EntityCategory, Platform, UnitOfElectricCurrent, @@ -83,6 +84,14 @@ BOOST_STATE_MAP = { clusters.WaterHeaterManagement.Enums.BoostStateEnum.kUnknownEnumValue: None, } +ESA_STATE_MAP = { + clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOffline: "offline", + clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOnline: "online", + clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kFault: "fault", + clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kPowerAdjustActive: "power_adjust_active", + clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kPaused: "paused", +} + EVSE_FAULT_STATE_MAP = { clusters.EnergyEvse.Enums.FaultStateEnum.kNoError: "no_error", clusters.EnergyEvse.Enums.FaultStateEnum.kMeterFailure: "meter_failure", @@ -102,6 +111,16 @@ EVSE_FAULT_STATE_MAP = { clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other", } +PUMP_CONTROL_MODE_MAP = { + clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantSpeed: "constant_speed", + clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantPressure: "constant_pressure", + clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kProportionalPressure: "proportional_pressure", + clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantFlow: "constant_flow", + clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantTemperature: "constant_temperature", + clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kAutomatic: "automatic", + clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None, +} + async def async_setup_entry( hass: HomeAssistant, @@ -744,6 +763,25 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalEnergyMeasurementCumulativeEnergyExported", + translation_key="energy_exported", + 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_INCREASING, + # id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh) + measurement_to_ha=lambda x: x.energy, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyExported, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -929,6 +967,21 @@ 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="TargetPositionLiftPercent100ths", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + translation_key="window_covering_target_position", + measurement_to_ha=lambda x: round((10000 - x) / 100), + native_unit_of_measurement=PERCENTAGE, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.WindowCovering.Attributes.TargetPositionLiftPercent100ths, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -1042,4 +1095,44 @@ DISCOVERY_SCHEMAS = [ clusters.WaterHeaterManagement.Attributes.EstimatedHeatRequired, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ESAState", + translation_key="esa_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(ESA_STATE_MAP.values()), + measurement_to_ha=ESA_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.DeviceEnergyManagement.Attributes.ESAState,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PumpControlMode", + translation_key="pump_control_mode", + device_class=SensorDeviceClass.ENUM, + options=[ + mode for mode in PUMP_CONTROL_MODE_MAP.values() if mode is not None + ], + measurement_to_ha=PUMP_CONTROL_MODE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.PumpConfigurationAndControl.Attributes.ControlMode, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PumpSpeed", + translation_key="pump_speed", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PumpConfigurationAndControl.Attributes.Speed,), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index b8e8c63502c..7cae16c5e9b 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -176,6 +176,12 @@ }, "temperature_offset": { "name": "Temperature offset" + }, + "pir_occupied_to_unoccupied_delay": { + "name": "Occupied to unoccupied delay" + }, + "auto_relock_timer": { + "name": "Automatic relock timer" } }, "light": { @@ -233,8 +239,26 @@ "laundry_washer_spin_speed": { "name": "Spin speed" }, + "pump_operation_mode": { + "name": "mode", + "state": { + "local": "Local", + "maximum": "Maximum", + "minimum": "Minimum", + "normal": "[%key:common::state::normal%]" + } + }, "water_heater_mode": { "name": "Water heater mode" + }, + "door_lock_sound_volume": { + "name": "Sound volume", + "state": { + "silent": "Silent", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } } }, "sensor": { @@ -303,6 +327,19 @@ "current_phase": { "name": "Current phase" }, + "energy_exported": { + "name": "Energy exported" + }, + "esa_state": { + "name": "Appliance energy state", + "state": { + "offline": "Offline", + "online": "Online", + "fault": "[%key:common::state::fault%]", + "power_adjust_active": "Power adjust", + "paused": "[%key:common::state::paused%]" + } + }, "evse_fault_state": { "name": "Fault state", "state": { @@ -324,6 +361,20 @@ "other": "Other fault" } }, + "pump_control_mode": { + "name": "Control mode", + "state": { + "constant_flow": "Constant flow", + "constant_pressure": "Constant pressure", + "constant_speed": "Constant speed", + "constant_temperature": "Constant temp", + "proportional_pressure": "Proportional pressure", + "automatic": "Automatic" + } + }, + "pump_speed": { + "name": "Rotation speed" + }, "evse_circuit_capacity": { "name": "Circuit capacity" }, @@ -338,6 +389,9 @@ }, "evse_user_max_charge_current": { "name": "User max charge current" + }, + "window_covering_target_position": { + "name": "Target opening position" } }, "switch": { diff --git a/homeassistant/components/matter/water_heater.py b/homeassistant/components/matter/water_heater.py index 07c011554fa..e453a8be067 100644 --- a/homeassistant/components/matter/water_heater.py +++ b/homeassistant/components/matter/water_heater.py @@ -108,6 +108,11 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity): await self.send_device_command( clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info) ) + # Trigger CancelBoost command for other modes + else: + await self.send_device_command( + clusters.WaterHeaterManagement.Commands.CancelBoost() + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on water heater.""" diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 296da4f0ab4..69a0eb8a553 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -93,7 +93,7 @@ class MaxCubeClimate(ClimateEntity): ] @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" temp = self._device.min_temperature or MIN_TEMPERATURE # OFF_TEMPERATURE (always off) a is valid temperature to maxcube but not to Home Assistant. @@ -101,7 +101,7 @@ class MaxCubeClimate(ClimateEntity): return max(temp, MIN_TEMPERATURE) @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self._device.max_temperature or MAX_TEMPERATURE diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 3ce80f497ef..20068efccef 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.05.22"], + "requirements": ["yt-dlp[default]==2025.06.09"], "single_config_entry": true } diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index 4349362b13a..c9caa2c4a91 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -2,7 +2,9 @@ from collections.abc import Iterable from dataclasses import dataclass, field +import logging import time +from typing import cast import voluptuous as vol @@ -14,9 +16,17 @@ from homeassistant.const import ( SERVICE_VOLUME_SET, ) from homeassistant.core import Context, HomeAssistant, State -from homeassistant.helpers import intent +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, intent -from . import ATTR_MEDIA_VOLUME_LEVEL, DOMAIN, MediaPlayerDeviceClass +from . import ( + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN, + SERVICE_PLAY_MEDIA, + SERVICE_SEARCH_MEDIA, + MediaPlayerDeviceClass, + SearchMedia, +) from .const import MediaPlayerEntityFeature, MediaPlayerState INTENT_MEDIA_PAUSE = "HassMediaPause" @@ -24,6 +34,9 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" INTENT_MEDIA_NEXT = "HassMediaNext" INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" INTENT_SET_VOLUME = "HassSetVolume" +INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay" + +_LOGGER = logging.getLogger(__name__) @dataclass @@ -109,6 +122,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: device_classes={MediaPlayerDeviceClass}, ), ) + intent.async_register(hass, MediaSearchAndPlayHandler()) class MediaPauseHandler(intent.ServiceIntentHandler): @@ -207,3 +221,121 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler): return await super().async_handle_states( intent_obj, match_result, match_constraints ) + + +class MediaSearchAndPlayHandler(intent.IntentHandler): + """Handle HassMediaSearchAndPlay intents.""" + + description = "Searches for media and plays the first result" + + intent_type = INTENT_MEDIA_SEARCH_AND_PLAY + slot_schema = { + vol.Required("search_query"): cv.string, + # Optional name/area/floor slots handled by intent matcher + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + search_query = slots["search_query"]["value"] + + # Entity name to match + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + + # Get area/floor info + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + + floor_slot = slots.get("floor", {}) + floor_id = floor_slot.get("value") + + # Find matching entities + match_constraints = intent.MatchTargetsConstraints( + name=entity_name, + area_name=area_id, + floor_name=floor_id, + domains={DOMAIN}, + assistant=intent_obj.assistant, + features=MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, + match_constraints, + intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ), + ) + + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + target_entity = match_result.states[0] + target_entity_id = target_entity.entity_id + + # 1. Search Media + try: + search_response = await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH_MEDIA, + { + "search_query": search_query, + }, + target={ + "entity_id": target_entity_id, + }, + blocking=True, + context=intent_obj.context, + return_response=True, + ) + except HomeAssistantError as err: + _LOGGER.error("Error calling search_media: %s", err) + raise intent.IntentHandleError(f"Error searching media: {err}") from err + + if ( + not search_response + or not ( + entity_response := cast( + SearchMedia, search_response.get(target_entity_id) + ) + ) + or not (results := entity_response.result) + ): + # No results found + return intent_obj.create_response() + + # 2. Play Media (first result) + first_result = results[0] + try: + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": target_entity_id, + "media_content_id": first_result.media_content_id, + "media_content_type": first_result.media_content_type, + }, + blocking=True, + context=intent_obj.context, + ) + except HomeAssistantError as err: + _LOGGER.error("Error calling play_media: %s", err) + raise intent.IntentHandleError(f"Error playing media: {err}") from err + + # Success + response = intent_obj.create_response() + response.async_set_speech_slots({"media": first_result.as_dict()}) + response.response_type = intent.IntentResponseType.ACTION_DONE + return response diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index 4561c38ce80..bccbe9f66ac 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -165,7 +165,7 @@ class MediaroomDevice(MediaPlayerEntity): self._unique_id = None @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available diff --git a/homeassistant/components/melissa/climate.py b/homeassistant/components/melissa/climate.py index ff68820d70f..bee457bada9 100644 --- a/homeassistant/components/melissa/climate.py +++ b/homeassistant/components/melissa/climate.py @@ -57,6 +57,7 @@ async def async_setup_platform( class MelissaClimate(ClimateEntity): """Representation of a Melissa Climate device.""" + _attr_fan_modes = FAN_MODES _attr_hvac_modes = OP_MODES _attr_supported_features = ( ClimateEntityFeature.FAN_MODE @@ -64,11 +65,14 @@ class MelissaClimate(ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) + _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = 16 + _attr_max_temp = 30 def __init__(self, api, serial_number, init_data): """Initialize the climate device.""" - self._name = init_data["name"] + self._attr_name = init_data["name"] self._api = api self._serial_number = serial_number self._data = init_data["controller_log"] @@ -76,36 +80,26 @@ class MelissaClimate(ClimateEntity): self._cur_settings = None @property - def name(self): - """Return the name of the thermostat, if any.""" - return self._name - - @property - def fan_mode(self): + def fan_mode(self) -> str | None: """Return the current fan mode.""" if self._cur_settings is not None: return self.melissa_fan_to_hass(self._cur_settings[self._api.FAN]) return None @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" if self._data: return self._data[self._api.TEMP] return None @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity value.""" if self._data: return self._data[self._api.HUMIDITY] return None - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return PRECISION_WHOLE - @property def hvac_mode(self) -> HVACMode | None: """Return the current operation mode.""" @@ -123,27 +117,12 @@ class MelissaClimate(ClimateEntity): return self.melissa_op_to_hass(self._cur_settings[self._api.MODE]) @property - def fan_modes(self): - """List of available fan modes.""" - return FAN_MODES - - @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self._cur_settings is None: return None return self._cur_settings[self._api.TEMP] - @property - def min_temp(self): - """Return the minimum supported temperature for the thermostat.""" - return 16 - - @property - def max_temp(self): - """Return the maximum supported temperature for the thermostat.""" - return 30 - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 1d516bbc4f5..913d87fe3d7 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -4,10 +4,10 @@ from __future__ import annotations import asyncio import logging -import re -from typing import Any import datapoint +import datapoint.Forecast +import datapoint.Manager from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -17,9 +17,8 @@ from homeassistant.const import ( CONF_NAME, Platform, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator @@ -30,11 +29,9 @@ from .const import ( METOFFICE_DAILY_COORDINATOR, METOFFICE_HOURLY_COORDINATOR, METOFFICE_NAME, - MODE_3HOURLY, - MODE_DAILY, + METOFFICE_TWICE_DAILY_COORDINATOR, ) -from .data import MetOfficeData -from .helpers import fetch_data, fetch_site +from .helpers import fetch_data _LOGGER = logging.getLogger(__name__) @@ -51,59 +48,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinates = f"{latitude}_{longitude}" - @callback - def update_unique_id( - entity_entry: er.RegistryEntry, - ) -> dict[str, Any] | None: - """Update unique ID of entity entry.""" + connection = datapoint.Manager.Manager(api_key=api_key) - if entity_entry.domain != Platform.SENSOR: - return None - - name_to_key = { - "Station Name": "name", - "Weather": "weather", - "Temperature": "temperature", - "Feels Like Temperature": "feels_like_temperature", - "Wind Speed": "wind_speed", - "Wind Direction": "wind_direction", - "Wind Gust": "wind_gust", - "Visibility": "visibility", - "Visibility Distance": "visibility_distance", - "UV Index": "uv", - "Probability of Precipitation": "precipitation", - "Humidity": "humidity", - } - - match = re.search(f"(?P.*)_{coordinates}.*", entity_entry.unique_id) - - if match is None: - return None - - if (name := match.group("name")) in name_to_key: - return { - "new_unique_id": entity_entry.unique_id.replace(name, name_to_key[name]) - } - return None - - await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) - - connection = datapoint.connection(api_key=api_key) - - site = await hass.async_add_executor_job( - fetch_site, connection, latitude, longitude - ) - if site is None: - raise ConfigEntryNotReady - - async def async_update_3hourly() -> MetOfficeData: + async def async_update_hourly() -> datapoint.Forecast: return await hass.async_add_executor_job( - fetch_data, connection, site, MODE_3HOURLY + fetch_data, connection, latitude, longitude, "hourly" ) - async def async_update_daily() -> MetOfficeData: + async def async_update_daily() -> datapoint.Forecast: return await hass.async_add_executor_job( - fetch_data, connection, site, MODE_DAILY + fetch_data, connection, latitude, longitude, "daily" + ) + + async def async_update_twice_daily() -> datapoint.Forecast: + return await hass.async_add_executor_job( + fetch_data, connection, latitude, longitude, "twice-daily" ) metoffice_hourly_coordinator = TimestampDataUpdateCoordinator( @@ -111,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, config_entry=entry, name=f"MetOffice Hourly Coordinator for {site_name}", - update_method=async_update_3hourly, + update_method=async_update_hourly, update_interval=DEFAULT_SCAN_INTERVAL, ) @@ -124,10 +83,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=DEFAULT_SCAN_INTERVAL, ) + metoffice_twice_daily_coordinator = TimestampDataUpdateCoordinator( + hass, + _LOGGER, + config_entry=entry, + name=f"MetOffice Twice Daily Coordinator for {site_name}", + update_method=async_update_twice_daily, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + metoffice_hass_data = hass.data.setdefault(DOMAIN, {}) metoffice_hass_data[entry.entry_id] = { METOFFICE_HOURLY_COORDINATOR: metoffice_hourly_coordinator, METOFFICE_DAILY_COORDINATOR: metoffice_daily_coordinator, + METOFFICE_TWICE_DAILY_COORDINATOR: metoffice_twice_daily_coordinator, METOFFICE_NAME: site_name, METOFFICE_COORDINATES: coordinates, } diff --git a/homeassistant/components/metoffice/config_flow.py b/homeassistant/components/metoffice/config_flow.py index d46e537dadb..81369daf09a 100644 --- a/homeassistant/components/metoffice/config_flow.py +++ b/homeassistant/components/metoffice/config_flow.py @@ -2,10 +2,14 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any import datapoint +from datapoint.exceptions import APIException +import datapoint.Manager +from requests import HTTPError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -15,30 +19,41 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from .const import DOMAIN -from .helpers import fetch_site _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: +async def validate_input( + hass: HomeAssistant, latitude: float, longitude: float, api_key: str +) -> dict[str, Any]: """Validate that the user input allows us to connect to DataPoint. Data has the keys from DATA_SCHEMA with values provided by the user. """ - latitude = data[CONF_LATITUDE] - longitude = data[CONF_LONGITUDE] - api_key = data[CONF_API_KEY] + errors = {} + connection = datapoint.Manager.Manager(api_key=api_key) - connection = datapoint.connection(api_key=api_key) + try: + forecast = await hass.async_add_executor_job( + connection.get_forecast, + latitude, + longitude, + "daily", + False, + ) - site = await hass.async_add_executor_job( - fetch_site, connection, latitude, longitude - ) + except (HTTPError, APIException) as err: + if isinstance(err, HTTPError) and err.response.status_code == 401: + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return {"site_name": forecast.name, "errors": errors} - if site is None: - raise CannotConnect - - return {"site_name": site.name} + return {"errors": errors} class MetOfficeConfigFlow(ConfigFlow, domain=DOMAIN): @@ -57,15 +72,17 @@ class MetOfficeConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - user_input[CONF_NAME] = info["site_name"] + result = await validate_input( + self.hass, + latitude=user_input[CONF_LATITUDE], + longitude=user_input[CONF_LONGITUDE], + api_key=user_input[CONF_API_KEY], + ) + + errors = result["errors"] + + if not errors: + user_input[CONF_NAME] = result["site_name"] return self.async_create_entry( title=user_input[CONF_NAME], data=user_input ) @@ -83,7 +100,51 @@ class MetOfficeConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors = {} + + entry = self._get_reauth_entry() + if user_input is not None: + result = await validate_input( + self.hass, + latitude=entry.data[CONF_LATITUDE], + longitude=entry.data[CONF_LONGITUDE], + api_key=user_input[CONF_API_KEY], + ) + + errors = result["errors"] + + if not errors: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + description_placeholders={ + "docs_url": ("https://www.home-assistant.io/integrations/metoffice") + }, + errors=errors, ) diff --git a/homeassistant/components/metoffice/const.py b/homeassistant/components/metoffice/const.py index 966aec7d381..e5ba50f2a90 100644 --- a/homeassistant/components/metoffice/const.py +++ b/homeassistant/components/metoffice/const.py @@ -18,6 +18,17 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_UV_INDEX, + ATTR_FORECAST_WIND_BEARING, ) DOMAIN = "metoffice" @@ -30,25 +41,23 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=15) METOFFICE_COORDINATES = "metoffice_coordinates" METOFFICE_HOURLY_COORDINATOR = "metoffice_hourly_coordinator" METOFFICE_DAILY_COORDINATOR = "metoffice_daily_coordinator" +METOFFICE_TWICE_DAILY_COORDINATOR = "metoffice_twice_daily_coordinator" METOFFICE_MONITORED_CONDITIONS = "metoffice_monitored_conditions" METOFFICE_NAME = "metoffice_name" -MODE_3HOURLY = "3hourly" -MODE_DAILY = "daily" - -CONDITION_CLASSES: dict[str, list[str]] = { - ATTR_CONDITION_CLEAR_NIGHT: ["0"], - ATTR_CONDITION_CLOUDY: ["7", "8"], - ATTR_CONDITION_FOG: ["5", "6"], - ATTR_CONDITION_HAIL: ["19", "20", "21"], - ATTR_CONDITION_LIGHTNING: ["30"], - ATTR_CONDITION_LIGHTNING_RAINY: ["28", "29"], - ATTR_CONDITION_PARTLYCLOUDY: ["2", "3"], - ATTR_CONDITION_POURING: ["13", "14", "15"], - ATTR_CONDITION_RAINY: ["9", "10", "11", "12"], - ATTR_CONDITION_SNOWY: ["22", "23", "24", "25", "26", "27"], - ATTR_CONDITION_SNOWY_RAINY: ["16", "17", "18"], - ATTR_CONDITION_SUNNY: ["1"], +CONDITION_CLASSES: dict[str, list[int]] = { + ATTR_CONDITION_CLEAR_NIGHT: [0], + ATTR_CONDITION_CLOUDY: [7, 8], + ATTR_CONDITION_FOG: [5, 6], + ATTR_CONDITION_HAIL: [19, 20, 21], + ATTR_CONDITION_LIGHTNING: [30], + ATTR_CONDITION_LIGHTNING_RAINY: [28, 29], + ATTR_CONDITION_PARTLYCLOUDY: [2, 3], + ATTR_CONDITION_POURING: [13, 14, 15], + ATTR_CONDITION_RAINY: [9, 10, 11, 12], + ATTR_CONDITION_SNOWY: [22, 23, 24, 25, 26, 27], + ATTR_CONDITION_SNOWY_RAINY: [16, 17, 18], + ATTR_CONDITION_SUNNY: [1], ATTR_CONDITION_WINDY: [], ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], @@ -59,20 +68,53 @@ CONDITION_MAP = { for cond_code in cond_codes } -VISIBILITY_CLASSES = { - "VP": "Very Poor", - "PO": "Poor", - "MO": "Moderate", - "GO": "Good", - "VG": "Very Good", - "EX": "Excellent", +HOURLY_FORECAST_ATTRIBUTE_MAP: dict[str, str] = { + ATTR_FORECAST_CONDITION: "significantWeatherCode", + ATTR_FORECAST_NATIVE_APPARENT_TEMP: "feelsLikeTemperature", + ATTR_FORECAST_NATIVE_PRESSURE: "mslp", + ATTR_FORECAST_NATIVE_TEMP: "screenTemperature", + ATTR_FORECAST_PRECIPITATION: "totalPrecipAmount", + ATTR_FORECAST_PRECIPITATION_PROBABILITY: "probOfPrecipitation", + ATTR_FORECAST_UV_INDEX: "uvIndex", + ATTR_FORECAST_WIND_BEARING: "windDirectionFrom10m", + ATTR_FORECAST_NATIVE_WIND_SPEED: "windSpeed10m", + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: "windGustSpeed10m", } -VISIBILITY_DISTANCE_CLASSES = { - "VP": "<1", - "PO": "1-4", - "MO": "4-10", - "GO": "10-20", - "VG": "20-40", - "EX": ">40", +DAILY_FORECAST_ATTRIBUTE_MAP: dict[str, str] = { + ATTR_FORECAST_CONDITION: "daySignificantWeatherCode", + ATTR_FORECAST_NATIVE_APPARENT_TEMP: "dayMaxFeelsLikeTemp", + ATTR_FORECAST_NATIVE_PRESSURE: "middayMslp", + ATTR_FORECAST_NATIVE_TEMP: "dayMaxScreenTemperature", + ATTR_FORECAST_NATIVE_TEMP_LOW: "nightMinScreenTemperature", + ATTR_FORECAST_PRECIPITATION_PROBABILITY: "dayProbabilityOfPrecipitation", + ATTR_FORECAST_UV_INDEX: "maxUvIndex", + ATTR_FORECAST_WIND_BEARING: "midday10MWindDirection", + ATTR_FORECAST_NATIVE_WIND_SPEED: "midday10MWindSpeed", + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: "midday10MWindGust", +} + +DAY_FORECAST_ATTRIBUTE_MAP: dict[str, str] = { + ATTR_FORECAST_CONDITION: "daySignificantWeatherCode", + ATTR_FORECAST_NATIVE_APPARENT_TEMP: "dayMaxFeelsLikeTemp", + ATTR_FORECAST_NATIVE_PRESSURE: "middayMslp", + ATTR_FORECAST_NATIVE_TEMP: "dayUpperBoundMaxTemp", + ATTR_FORECAST_NATIVE_TEMP_LOW: "dayLowerBoundMaxTemp", + ATTR_FORECAST_PRECIPITATION_PROBABILITY: "dayProbabilityOfPrecipitation", + ATTR_FORECAST_UV_INDEX: "maxUvIndex", + ATTR_FORECAST_WIND_BEARING: "midday10MWindDirection", + ATTR_FORECAST_NATIVE_WIND_SPEED: "midday10MWindSpeed", + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: "midday10MWindGust", +} + +NIGHT_FORECAST_ATTRIBUTE_MAP: dict[str, str] = { + ATTR_FORECAST_CONDITION: "nightSignificantWeatherCode", + ATTR_FORECAST_NATIVE_APPARENT_TEMP: "nightMinFeelsLikeTemp", + ATTR_FORECAST_NATIVE_PRESSURE: "midnightMslp", + ATTR_FORECAST_NATIVE_TEMP: "nightUpperBoundMinTemp", + ATTR_FORECAST_NATIVE_TEMP_LOW: "nightLowerBoundMinTemp", + ATTR_FORECAST_PRECIPITATION_PROBABILITY: "nightProbabilityOfPrecipitation", + ATTR_FORECAST_WIND_BEARING: "midnight10MWindDirection", + ATTR_FORECAST_NATIVE_WIND_SPEED: "midnight10MWindSpeed", + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: "midnight10MWindGust", } diff --git a/homeassistant/components/metoffice/data.py b/homeassistant/components/metoffice/data.py deleted file mode 100644 index 651e56c3adc..00000000000 --- a/homeassistant/components/metoffice/data.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Common Met Office Data class used by both sensor and entity.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from datapoint.Forecast import Forecast -from datapoint.Site import Site -from datapoint.Timestep import Timestep - - -@dataclass -class MetOfficeData: - """Data structure for MetOffice weather and forecast.""" - - now: Forecast - forecast: list[Timestep] - site: Site diff --git a/homeassistant/components/metoffice/helpers.py b/homeassistant/components/metoffice/helpers.py index 56d4d8f971b..e6bb8a34020 100644 --- a/homeassistant/components/metoffice/helpers.py +++ b/homeassistant/components/metoffice/helpers.py @@ -3,51 +3,40 @@ from __future__ import annotations import logging +from typing import Any, Literal import datapoint -from datapoint.Site import Site +from datapoint.Forecast import Forecast +from requests import HTTPError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import UpdateFailed -from homeassistant.util.dt import utcnow - -from .const import MODE_3HOURLY -from .data import MetOfficeData _LOGGER = logging.getLogger(__name__) -def fetch_site( - connection: datapoint.Manager, latitude: float, longitude: float -) -> Site | None: - """Fetch site information from Datapoint API.""" - try: - return connection.get_nearest_forecast_site( - latitude=latitude, longitude=longitude - ) - except datapoint.exceptions.APIException as err: - _LOGGER.error("Received error from Met Office Datapoint: %s", err) - return None - - -def fetch_data(connection: datapoint.Manager, site: Site, mode: str) -> MetOfficeData: +def fetch_data( + connection: datapoint.Manager, + latitude: float, + longitude: float, + frequency: Literal["daily", "twice-daily", "hourly"], +) -> Forecast: """Fetch weather and forecast from Datapoint API.""" try: - forecast = connection.get_forecast_for_site(site.location_id, mode) + return connection.get_forecast( + latitude, longitude, frequency, convert_weather_code=False + ) except (ValueError, datapoint.exceptions.APIException) as err: _LOGGER.error("Check Met Office connection: %s", err.args) raise UpdateFailed from err + except HTTPError as err: + if err.response.status_code == 401: + raise ConfigEntryAuthFailed from err + raise - time_now = utcnow() - return MetOfficeData( - now=forecast.now(), - forecast=[ - timestep - for day in forecast.days - for timestep in day.timesteps - if timestep.date > time_now - and ( - mode == MODE_3HOURLY or timestep.date.hour > 6 - ) # ensures only one result per day in MODE_DAILY - ], - site=site, - ) + +def get_attribute(data: dict[str, Any] | None, attr_name: str) -> Any | None: + """Get an attribute from weather data.""" + if data: + return data.get(attr_name, {}).get("value") + return None diff --git a/homeassistant/components/metoffice/manifest.json b/homeassistant/components/metoffice/manifest.json index 17643d7e061..730c75223fd 100644 --- a/homeassistant/components/metoffice/manifest.json +++ b/homeassistant/components/metoffice/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/metoffice", "iot_class": "cloud_polling", "loggers": ["datapoint"], - "requirements": ["datapoint==0.9.9"] + "requirements": ["datapoint==0.12.1"] } diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 5a256144d11..c6b9f96514b 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -2,17 +2,21 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any -from datapoint.Element import Element +from datapoint.Forecast import Forecast from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + DEGREE, PERCENTAGE, UV_INDEX, UnitOfLength, @@ -20,6 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( @@ -33,107 +38,122 @@ from .const import ( CONDITION_MAP, DOMAIN, METOFFICE_COORDINATES, - METOFFICE_DAILY_COORDINATOR, METOFFICE_HOURLY_COORDINATOR, METOFFICE_NAME, - MODE_DAILY, - VISIBILITY_CLASSES, - VISIBILITY_DISTANCE_CLASSES, ) -from .data import MetOfficeData +from .helpers import get_attribute ATTR_LAST_UPDATE = "last_update" -ATTR_SENSOR_ID = "sensor_id" -ATTR_SITE_ID = "site_id" -ATTR_SITE_NAME = "site_name" -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +@dataclass(frozen=True, kw_only=True) +class MetOfficeSensorEntityDescription(SensorEntityDescription): + """Entity description class for MetOffice sensors.""" + + native_attr_name: str + + +SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( + MetOfficeSensorEntityDescription( key="name", + native_attr_name="name", name="Station name", icon="mdi:label-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="weather", + native_attr_name="significantWeatherCode", name="Weather", icon="mdi:weather-sunny", # but will adapt to current conditions entity_registry_enabled_default=True, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="temperature", + native_attr_name="screenTemperature", name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - icon=None, entity_registry_enabled_default=True, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="feels_like_temperature", + native_attr_name="feelsLikeTemperature", name="Feels like temperature", device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, icon=None, entity_registry_enabled_default=False, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="wind_speed", + native_attr_name="windSpeed10m", name="Wind speed", - native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, # Hint mph because that's the preferred unit for wind speeds in UK # This can be removed if we add a mixed metric/imperial unit system for UK users suggested_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=True, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="wind_direction", + native_attr_name="windDirectionFrom10m", name="Wind direction", + native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, icon="mdi:compass-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="wind_gust", + native_attr_name="windGustSpeed10m", name="Wind gust", - native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, # Hint mph because that's the preferred unit for wind speeds in UK # This can be removed if we add a mixed metric/imperial unit system for UK users suggested_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="visibility", - name="Visibility", - icon="mdi:eye", - entity_registry_enabled_default=False, - ), - SensorEntityDescription( - key="visibility_distance", + native_attr_name="visibility", name="Visibility distance", - native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.METERS, icon="mdi:eye", entity_registry_enabled_default=False, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="uv", + native_attr_name="uvIndex", name="UV index", native_unit_of_measurement=UV_INDEX, icon="mdi:weather-sunny-alert", entity_registry_enabled_default=True, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="precipitation", + native_attr_name="probOfPrecipitation", + state_class=SensorStateClass.MEASUREMENT, name="Probability of precipitation", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-rainy", entity_registry_enabled_default=True, ), - SensorEntityDescription( + MetOfficeSensorEntityDescription( key="humidity", + native_attr_name="screenRelativeHumidity", name="Humidity", device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon=None, entity_registry_enabled_default=False, @@ -147,23 +167,37 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Met Office weather sensor platform.""" + entity_registry = er.async_get(hass) hass_data = hass.data[DOMAIN][entry.entry_id] + # Remove daily entities from legacy config entries + for description in SENSOR_TYPES: + if entity_id := entity_registry.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + f"{description.key}_{hass_data[METOFFICE_COORDINATES]}_daily", + ): + entity_registry.async_remove(entity_id) + + # Remove old visibility sensors + if entity_id := entity_registry.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + f"visibility_distance_{hass_data[METOFFICE_COORDINATES]}_daily", + ): + entity_registry.async_remove(entity_id) + if entity_id := entity_registry.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + f"visibility_distance_{hass_data[METOFFICE_COORDINATES]}", + ): + entity_registry.async_remove(entity_id) + async_add_entities( [ MetOfficeCurrentSensor( hass_data[METOFFICE_HOURLY_COORDINATOR], hass_data, - True, - description, - ) - for description in SENSOR_TYPES - ] - + [ - MetOfficeCurrentSensor( - hass_data[METOFFICE_DAILY_COORDINATOR], - hass_data, - False, description, ) for description in SENSOR_TYPES @@ -173,64 +207,43 @@ async def async_setup_entry( class MetOfficeCurrentSensor( - CoordinatorEntity[DataUpdateCoordinator[MetOfficeData]], SensorEntity + CoordinatorEntity[DataUpdateCoordinator[Forecast]], SensorEntity ): """Implementation of a Met Office current weather condition sensor.""" _attr_attribution = ATTRIBUTION _attr_has_entity_name = True + entity_description: MetOfficeSensorEntityDescription + def __init__( self, - coordinator: DataUpdateCoordinator[MetOfficeData], + coordinator: DataUpdateCoordinator[Forecast], hass_data: dict[str, Any], - use_3hourly: bool, - description: SensorEntityDescription, + description: MetOfficeSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - mode_label = "3-hourly" if use_3hourly else "daily" self._attr_device_info = get_device_info( coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME] ) - self._attr_name = f"{description.name} {mode_label}" self._attr_unique_id = f"{description.key}_{hass_data[METOFFICE_COORDINATES]}" - if not use_3hourly: - self._attr_unique_id = f"{self._attr_unique_id}_{MODE_DAILY}" - self._attr_entity_registry_enabled_default = ( - self.entity_description.entity_registry_enabled_default and use_3hourly - ) @property def native_value(self) -> StateType: """Return the state of the sensor.""" - value = None + value = get_attribute( + self.coordinator.data.now(), self.entity_description.native_attr_name + ) - if self.entity_description.key == "visibility_distance" and hasattr( - self.coordinator.data.now, "visibility" + if ( + self.entity_description.native_attr_name == "significantWeatherCode" + and value is not None ): - value = VISIBILITY_DISTANCE_CLASSES.get( - self.coordinator.data.now.visibility.value - ) - - if self.entity_description.key == "visibility" and hasattr( - self.coordinator.data.now, "visibility" - ): - value = VISIBILITY_CLASSES.get(self.coordinator.data.now.visibility.value) - - elif self.entity_description.key == "weather" and hasattr( - self.coordinator.data.now, self.entity_description.key - ): - value = CONDITION_MAP.get(self.coordinator.data.now.weather.value) - - elif hasattr(self.coordinator.data.now, self.entity_description.key): - value = getattr(self.coordinator.data.now, self.entity_description.key) - - if isinstance(value, Element): - value = value.value + value = CONDITION_MAP.get(value) return value @@ -238,7 +251,7 @@ class MetOfficeCurrentSensor( def icon(self) -> str | None: """Return the icon for the entity card.""" value = self.entity_description.icon - if self.entity_description.key == "weather": + if self.entity_description.native_attr_name == "significantWeatherCode": value = self.state if value is None: value = "sunny" @@ -252,8 +265,5 @@ class MetOfficeCurrentSensor( def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the device.""" return { - ATTR_LAST_UPDATE: self.coordinator.data.now.date, - ATTR_SENSOR_ID: self.entity_description.key, - ATTR_SITE_ID: self.coordinator.data.site.location_id, - ATTR_SITE_NAME: self.coordinator.data.site.name, + ATTR_LAST_UPDATE: self.coordinator.data.now()["time"], } diff --git a/homeassistant/components/metoffice/strings.json b/homeassistant/components/metoffice/strings.json index 5a1c59bcfb7..d13e0b89f96 100644 --- a/homeassistant/components/metoffice/strings.json +++ b/homeassistant/components/metoffice/strings.json @@ -2,21 +2,29 @@ "config": { "step": { "user": { - "description": "The latitude and longitude will be used to find the closest weather station.", "title": "Connect to the UK Met Office", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]" } + }, + "reauth_confirm": { + "title": "Reauthenticate with DataHub API", + "description": "Please re-enter your DataHub API key. If you are still using an old Datapoint API key, you need to sign up for DataHub API now, see [documentation]({docs_url}) for details.", + "data": { + "api_key": "[%key:common::config_flow::data::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%]" } } } diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index d3f1320c47e..90fbc36f8fb 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -2,15 +2,23 @@ from __future__ import annotations +from datetime import datetime from typing import Any, cast -from datapoint.Timestep import Timestep +from datapoint.Forecast import Forecast as ForecastData from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, + ATTR_FORECAST_IS_DAYTIME, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, CoordinatorWeatherEntity, @@ -18,7 +26,12 @@ from homeassistant.components.weather import ( WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature +from homeassistant.const import ( + UnitOfLength, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -28,14 +41,18 @@ from . import get_device_info from .const import ( ATTRIBUTION, CONDITION_MAP, + DAILY_FORECAST_ATTRIBUTE_MAP, + DAY_FORECAST_ATTRIBUTE_MAP, DOMAIN, + HOURLY_FORECAST_ATTRIBUTE_MAP, METOFFICE_COORDINATES, METOFFICE_DAILY_COORDINATOR, METOFFICE_HOURLY_COORDINATOR, METOFFICE_NAME, - MODE_DAILY, + METOFFICE_TWICE_DAILY_COORDINATOR, + NIGHT_FORECAST_ATTRIBUTE_MAP, ) -from .data import MetOfficeData +from .helpers import get_attribute async def async_setup_entry( @@ -47,11 +64,11 @@ async def async_setup_entry( entity_registry = er.async_get(hass) hass_data = hass.data[DOMAIN][entry.entry_id] - # Remove hourly entity from legacy config entries + # Remove daily entity from legacy config entries if entity_id := entity_registry.async_get_entity_id( WEATHER_DOMAIN, DOMAIN, - _calculate_unique_id(hass_data[METOFFICE_COORDINATES], True), + f"{hass_data[METOFFICE_COORDINATES]}_daily", ): entity_registry.async_remove(entity_id) @@ -60,6 +77,7 @@ async def async_setup_entry( MetOfficeWeather( hass_data[METOFFICE_DAILY_COORDINATOR], hass_data[METOFFICE_HOURLY_COORDINATOR], + hass_data[METOFFICE_TWICE_DAILY_COORDINATOR], hass_data, ) ], @@ -67,138 +85,222 @@ async def async_setup_entry( ) -def _build_forecast_data(timestep: Timestep) -> Forecast: - data = Forecast(datetime=timestep.date.isoformat()) - if timestep.weather: - data[ATTR_FORECAST_CONDITION] = CONDITION_MAP.get(timestep.weather.value) - if timestep.precipitation: - data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = timestep.precipitation.value - if timestep.temperature: - data[ATTR_FORECAST_NATIVE_TEMP] = timestep.temperature.value - if timestep.wind_direction: - data[ATTR_FORECAST_WIND_BEARING] = timestep.wind_direction.value - if timestep.wind_speed: - data[ATTR_FORECAST_NATIVE_WIND_SPEED] = timestep.wind_speed.value +def _build_hourly_forecast_data(timestep: dict[str, Any]) -> Forecast: + data = Forecast(datetime=timestep["time"].isoformat()) + _populate_forecast_data(data, timestep, HOURLY_FORECAST_ATTRIBUTE_MAP) return data -def _calculate_unique_id(coordinates: str, use_3hourly: bool) -> str: - """Calculate unique ID.""" - if use_3hourly: - return coordinates - return f"{coordinates}_{MODE_DAILY}" +def _build_daily_forecast_data(timestep: dict[str, Any]) -> Forecast: + data = Forecast(datetime=timestep["time"].isoformat()) + _populate_forecast_data(data, timestep, DAILY_FORECAST_ATTRIBUTE_MAP) + return data + + +def _build_twice_daily_forecast_data(timestep: dict[str, Any]) -> Forecast: + data = Forecast(datetime=timestep["time"].isoformat()) + + # day and night forecasts have slightly different format + if "daySignificantWeatherCode" in timestep: + data[ATTR_FORECAST_IS_DAYTIME] = True + _populate_forecast_data(data, timestep, DAY_FORECAST_ATTRIBUTE_MAP) + else: + data[ATTR_FORECAST_IS_DAYTIME] = False + _populate_forecast_data(data, timestep, NIGHT_FORECAST_ATTRIBUTE_MAP) + return data + + +def _populate_forecast_data( + forecast: Forecast, timestep: dict[str, Any], mapping: dict[str, str] +) -> None: + def get_mapped_attribute(attr: str) -> Any: + if attr not in mapping: + return None + return get_attribute(timestep, mapping[attr]) + + weather_code = get_mapped_attribute(ATTR_FORECAST_CONDITION) + if weather_code is not None: + forecast[ATTR_FORECAST_CONDITION] = CONDITION_MAP.get(weather_code) + forecast[ATTR_FORECAST_NATIVE_APPARENT_TEMP] = get_mapped_attribute( + ATTR_FORECAST_NATIVE_APPARENT_TEMP + ) + forecast[ATTR_FORECAST_NATIVE_PRESSURE] = get_mapped_attribute( + ATTR_FORECAST_NATIVE_PRESSURE + ) + forecast[ATTR_FORECAST_NATIVE_TEMP] = get_mapped_attribute( + ATTR_FORECAST_NATIVE_TEMP + ) + forecast[ATTR_FORECAST_NATIVE_TEMP_LOW] = get_mapped_attribute( + ATTR_FORECAST_NATIVE_TEMP_LOW + ) + forecast[ATTR_FORECAST_PRECIPITATION] = get_mapped_attribute( + ATTR_FORECAST_PRECIPITATION + ) + forecast[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = get_mapped_attribute( + ATTR_FORECAST_PRECIPITATION_PROBABILITY + ) + forecast[ATTR_FORECAST_UV_INDEX] = get_mapped_attribute(ATTR_FORECAST_UV_INDEX) + forecast[ATTR_FORECAST_WIND_BEARING] = get_mapped_attribute( + ATTR_FORECAST_WIND_BEARING + ) + forecast[ATTR_FORECAST_NATIVE_WIND_SPEED] = get_mapped_attribute( + ATTR_FORECAST_NATIVE_WIND_SPEED + ) + forecast[ATTR_FORECAST_NATIVE_WIND_GUST_SPEED] = get_mapped_attribute( + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED + ) class MetOfficeWeather( CoordinatorWeatherEntity[ - TimestampDataUpdateCoordinator[MetOfficeData], - TimestampDataUpdateCoordinator[MetOfficeData], + TimestampDataUpdateCoordinator[ForecastData], + TimestampDataUpdateCoordinator[ForecastData], + TimestampDataUpdateCoordinator[ForecastData], ] ): """Implementation of a Met Office weather condition.""" _attr_attribution = ATTRIBUTION _attr_has_entity_name = True + _attr_name = None _attr_native_temperature_unit = UnitOfTemperature.CELSIUS - _attr_native_pressure_unit = UnitOfPressure.HPA - _attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + _attr_native_pressure_unit = UnitOfPressure.PA + _attr_native_precipitation_unit = UnitOfLength.MILLIMETERS + _attr_native_visibility_unit = UnitOfLength.METERS + _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND _attr_supported_features = ( - WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_DAILY + WeatherEntityFeature.FORECAST_HOURLY + | WeatherEntityFeature.FORECAST_TWICE_DAILY + | WeatherEntityFeature.FORECAST_DAILY ) def __init__( self, - coordinator_daily: TimestampDataUpdateCoordinator[MetOfficeData], - coordinator_hourly: TimestampDataUpdateCoordinator[MetOfficeData], + coordinator_daily: TimestampDataUpdateCoordinator[ForecastData], + coordinator_hourly: TimestampDataUpdateCoordinator[ForecastData], + coordinator_twice_daily: TimestampDataUpdateCoordinator[ForecastData], hass_data: dict[str, Any], ) -> None: """Initialise the platform with a data instance.""" - observation_coordinator = coordinator_daily + observation_coordinator = coordinator_hourly super().__init__( observation_coordinator, daily_coordinator=coordinator_daily, hourly_coordinator=coordinator_hourly, + twice_daily_coordinator=coordinator_twice_daily, ) self._attr_device_info = get_device_info( coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME] ) - self._attr_name = "Daily" - self._attr_unique_id = _calculate_unique_id( - hass_data[METOFFICE_COORDINATES], False - ) + self._attr_unique_id = hass_data[METOFFICE_COORDINATES] @property def condition(self) -> str | None: """Return the current condition.""" - if self.coordinator.data.now: - return CONDITION_MAP.get(self.coordinator.data.now.weather.value) + weather_now = self.coordinator.data.now() + value = get_attribute(weather_now, "significantWeatherCode") + + if value is not None: + return CONDITION_MAP.get(value) return None @property def native_temperature(self) -> float | None: """Return the platform temperature.""" - weather_now = self.coordinator.data.now - if weather_now.temperature: - value = weather_now.temperature.value - return float(value) if value is not None else None - return None + weather_now = self.coordinator.data.now() + value = get_attribute(weather_now, "screenTemperature") + return float(value) if value is not None else None + + @property + def native_dew_point(self) -> float | None: + """Return the dew point.""" + weather_now = self.coordinator.data.now() + value = get_attribute(weather_now, "screenDewPointTemperature") + return float(value) if value is not None else None @property def native_pressure(self) -> float | None: """Return the mean sea-level pressure.""" - weather_now = self.coordinator.data.now - if weather_now and weather_now.pressure: - value = weather_now.pressure.value - return float(value) if value is not None else None - return None + weather_now = self.coordinator.data.now() + value = get_attribute(weather_now, "mslp") + return float(value) if value is not None else None @property def humidity(self) -> float | None: """Return the relative humidity.""" - weather_now = self.coordinator.data.now - if weather_now and weather_now.humidity: - value = weather_now.humidity.value - return float(value) if value is not None else None - return None + weather_now = self.coordinator.data.now() + value = get_attribute(weather_now, "screenRelativeHumidity") + return float(value) if value is not None else None + + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + weather_now = self.coordinator.data.now() + value = get_attribute(weather_now, "uvIndex") + return float(value) if value is not None else None + + @property + def native_visibility(self) -> float | None: + """Return the visibility.""" + weather_now = self.coordinator.data.now() + value = get_attribute(weather_now, "visibility") + return float(value) if value is not None else None @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" - weather_now = self.coordinator.data.now - if weather_now and weather_now.wind_speed: - value = weather_now.wind_speed.value - return float(value) if value is not None else None - return None + weather_now = self.coordinator.data.now() + value = get_attribute(weather_now, "windSpeed10m") + return float(value) if value is not None else None @property - def wind_bearing(self) -> str | None: + def wind_bearing(self) -> float | None: """Return the wind bearing.""" - weather_now = self.coordinator.data.now - if weather_now and weather_now.wind_direction: - value = weather_now.wind_direction.value - return str(value) if value is not None else None - return None + weather_now = self.coordinator.data.now() + value = get_attribute(weather_now, "windDirectionFrom10m") + return float(value) if value is not None else None @callback def _async_forecast_daily(self) -> list[Forecast] | None: - """Return the twice daily forecast in native units.""" + """Return the daily forecast in native units.""" coordinator = cast( - TimestampDataUpdateCoordinator[MetOfficeData], + TimestampDataUpdateCoordinator[ForecastData], self.forecast_coordinators["daily"], ) + timesteps = coordinator.data.timesteps return [ - _build_forecast_data(timestep) for timestep in coordinator.data.forecast + _build_daily_forecast_data(timestep) + for timestep in timesteps + if timestep["time"] > datetime.now(tz=timesteps[0]["time"].tzinfo) ] @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" coordinator = cast( - TimestampDataUpdateCoordinator[MetOfficeData], + TimestampDataUpdateCoordinator[ForecastData], self.forecast_coordinators["hourly"], ) + + timesteps = coordinator.data.timesteps return [ - _build_forecast_data(timestep) for timestep in coordinator.data.forecast + _build_hourly_forecast_data(timestep) + for timestep in timesteps + if timestep["time"] > datetime.now(tz=timesteps[0]["time"].tzinfo) + ] + + @callback + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + coordinator = cast( + TimestampDataUpdateCoordinator[ForecastData], + self.forecast_coordinators["twice_daily"], + ) + timesteps = coordinator.data.timesteps + return [ + _build_twice_daily_forecast_data(timestep) + for timestep in timesteps + if timestep["time"] > datetime.now(tz=timesteps[0]["time"].tzinfo) ] diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 23c9885e0c5..5a8d9c3dae0 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -22,6 +22,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) @@ -31,9 +32,9 @@ ATTR_PERSON = "person" CONF_AZURE_REGION = "azure_region" -DATA_MICROSOFT_FACE = "microsoft_face" DEFAULT_TIMEOUT = 10 DOMAIN = "microsoft_face" +DATA_MICROSOFT_FACE: HassKey[MicrosoftFace] = HassKey(DOMAIN) FACE_API_URL = "api.cognitive.microsoft.com/face/v1.0/{0}" @@ -80,11 +81,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: logging.getLogger(__name__), DOMAIN, hass ) entities: dict[str, MicrosoftFaceGroupEntity] = {} + domain_config: dict[str, Any] = config[DOMAIN] + azure_region: str = domain_config[CONF_AZURE_REGION] + api_key: str = domain_config[CONF_API_KEY] + timeout: int = domain_config[CONF_TIMEOUT] face = MicrosoftFace( hass, - config[DOMAIN].get(CONF_AZURE_REGION), - config[DOMAIN].get(CONF_API_KEY), - config[DOMAIN].get(CONF_TIMEOUT), + azure_region, + api_key, + timeout, component, entities, ) @@ -110,7 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if old_entity: await component.async_remove_entity(old_entity.entity_id) - entities[g_id] = MicrosoftFaceGroupEntity(hass, face, g_id, name) + entities[g_id] = MicrosoftFaceGroupEntity(face, g_id, name) await component.async_add_entities([entities[g_id]]) except HomeAssistantError as err: _LOGGER.error("Can't create group '%s' with error: %s", g_id, err) @@ -219,30 +224,20 @@ class MicrosoftFaceGroupEntity(Entity): _attr_should_poll = False - def __init__(self, hass, api, g_id, name): + def __init__(self, api: MicrosoftFace, g_id: str, name: str) -> None: """Initialize person/group entity.""" - self.hass = hass + self.entity_id = f"{DOMAIN}.{g_id}" self._api = api self._id = g_id - self._name = name + self._attr_name = name @property - def name(self): - """Return the name of the entity.""" - return self._name - - @property - def entity_id(self): - """Return entity id.""" - return f"{DOMAIN}.{self._id}" - - @property - def state(self): + def state(self) -> int: """Return the state of the entity.""" return len(self._api.store[self._id]) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" return dict(self._api.store[self._id]) @@ -250,19 +245,27 @@ class MicrosoftFaceGroupEntity(Entity): class MicrosoftFace: """Microsoft Face api for Home Assistant.""" - def __init__(self, hass, server_loc, api_key, timeout, component, entities): + def __init__( + self, + hass: HomeAssistant, + server_loc: str, + api_key: str, + timeout: int, + component: EntityComponent[MicrosoftFaceGroupEntity], + entities: dict[str, MicrosoftFaceGroupEntity], + ) -> None: """Initialize Microsoft Face api.""" self.hass = hass self.websession = async_get_clientsession(hass) self.timeout = timeout self._api_key = api_key self._server_url = f"https://{server_loc}.{FACE_API_URL}" - self._store = {} - self._component: EntityComponent[MicrosoftFaceGroupEntity] = component + self._store: dict[str, dict[str, Any]] = {} + self._component = component self._entities = entities @property - def store(self): + def store(self) -> dict[str, dict[str, Any]]: """Store group/person data and IDs.""" return self._store @@ -281,9 +284,7 @@ class MicrosoftFace: self._component.async_remove_entity(old_entity.entity_id) ) - self._entities[g_id] = MicrosoftFaceGroupEntity( - self.hass, self, g_id, group["name"] - ) + self._entities[g_id] = MicrosoftFaceGroupEntity(self, g_id, group["name"]) new_entities.append(self._entities[g_id]) persons = await self.call_api("get", f"persongroups/{g_id}/persons") @@ -313,8 +314,8 @@ class MicrosoftFace: try: async with asyncio.timeout(self.timeout): - response = await getattr(self.websession, method)( - url, data=payload, headers=headers, params=params + response = await self.websession.request( + method, url, data=payload, headers=headers, params=params ) answer = await response.json() diff --git a/homeassistant/components/microsoft_face_detect/image_processing.py b/homeassistant/components/microsoft_face_detect/image_processing.py index ce49f0b1f65..57e785ad328 100644 --- a/homeassistant/components/microsoft_face_detect/image_processing.py +++ b/homeassistant/components/microsoft_face_detect/image_processing.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING import voluptuous as vol @@ -11,9 +12,10 @@ from homeassistant.components.image_processing import ( ATTR_GENDER, ATTR_GLASSES, PLATFORM_SCHEMA as IMAGE_PROCESSING_PLATFORM_SCHEMA, + FaceInformation, ImageProcessingFaceEntity, ) -from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE +from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE, MicrosoftFace from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError @@ -54,43 +56,40 @@ async def async_setup_platform( ) -> None: """Set up the Microsoft Face detection platform.""" api = hass.data[DATA_MICROSOFT_FACE] - attributes = config[CONF_ATTRIBUTES] + attributes: list[str] = config[CONF_ATTRIBUTES] + source: list[dict[str, str]] = config[CONF_SOURCE] async_add_entities( MicrosoftFaceDetectEntity( camera[CONF_ENTITY_ID], api, attributes, camera.get(CONF_NAME) ) - for camera in config[CONF_SOURCE] + for camera in source ) class MicrosoftFaceDetectEntity(ImageProcessingFaceEntity): """Microsoft Face API entity for identify.""" - def __init__(self, camera_entity, api, attributes, name=None): + def __init__( + self, + camera_entity: str, + api: MicrosoftFace, + attributes: list[str], + name: str | None, + ) -> None: """Initialize Microsoft Face.""" super().__init__() self._api = api - self._camera = camera_entity + self._attr_camera_entity = camera_entity self._attributes = attributes if name: - self._name = name + self._attr_name = name else: - self._name = f"MicrosoftFace {split_entity_id(camera_entity)[1]}" + self._attr_name = f"MicrosoftFace {split_entity_id(camera_entity)[1]}" - @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - async def async_process_image(self, image): + async def async_process_image(self, image: bytes) -> None: """Process image. This method is a coroutine. @@ -112,12 +111,14 @@ class MicrosoftFaceDetectEntity(ImageProcessingFaceEntity): if not face_data: face_data = [] - faces = [] + faces: list[FaceInformation] = [] for face in face_data: - face_attr = {} + face_attr = FaceInformation() for attr in self._attributes: + if TYPE_CHECKING: + assert attr in SUPPORTED_ATTRIBUTES if attr in face["faceAttributes"]: - face_attr[attr] = face["faceAttributes"][attr] + face_attr[attr] = face["faceAttributes"][attr] # type: ignore[literal-required] if face_attr: faces.append(face_attr) diff --git a/homeassistant/components/microsoft_face_identify/image_processing.py b/homeassistant/components/microsoft_face_identify/image_processing.py index 025a7eccdda..ed793580e1b 100644 --- a/homeassistant/components/microsoft_face_identify/image_processing.py +++ b/homeassistant/components/microsoft_face_identify/image_processing.py @@ -10,9 +10,10 @@ from homeassistant.components.image_processing import ( ATTR_CONFIDENCE, CONF_CONFIDENCE, PLATFORM_SCHEMA as IMAGE_PROCESSING_PLATFORM_SCHEMA, + FaceInformation, ImageProcessingFaceEntity, ) -from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE +from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE, MicrosoftFace from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError @@ -37,8 +38,9 @@ async def async_setup_platform( ) -> None: """Set up the Microsoft Face identify platform.""" api = hass.data[DATA_MICROSOFT_FACE] - face_group = config[CONF_GROUP] - confidence = config[CONF_CONFIDENCE] + face_group: str = config[CONF_GROUP] + confidence: float = config[CONF_CONFIDENCE] + source: list[dict[str, str]] = config[CONF_SOURCE] async_add_entities( MicrosoftFaceIdentifyEntity( @@ -48,43 +50,35 @@ async def async_setup_platform( confidence, camera.get(CONF_NAME), ) - for camera in config[CONF_SOURCE] + for camera in source ) class MicrosoftFaceIdentifyEntity(ImageProcessingFaceEntity): """Representation of the Microsoft Face API entity for identify.""" - def __init__(self, camera_entity, api, face_group, confidence, name=None): + def __init__( + self, + camera_entity: str, + api: MicrosoftFace, + face_group: str, + confidence: float, + name: str | None, + ) -> None: """Initialize the Microsoft Face API.""" super().__init__() self._api = api - self._camera = camera_entity - self._confidence = confidence + self._attr_camera_entity = camera_entity + self._attr_confidence = confidence self._face_group = face_group if name: - self._name = name + self._attr_name = name else: - self._name = f"MicrosoftFace {split_entity_id(camera_entity)[1]}" + self._attr_name = f"MicrosoftFace {split_entity_id(camera_entity)[1]}" - @property - def confidence(self): - """Return minimum confidence for send events.""" - return self._confidence - - @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - async def async_process_image(self, image): + async def async_process_image(self, image: bytes) -> None: """Process image. This method is a coroutine. @@ -106,7 +100,7 @@ class MicrosoftFaceIdentifyEntity(ImageProcessingFaceEntity): return # Parse data - known_faces = [] + known_faces: list[FaceInformation] = [] total = 0 for face in detect: total += 1 diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 98a6919980a..9b9ec81bea9 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -26,6 +26,7 @@ PLATFORMS: list[Platform] = [ Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, + Platform.VACUUM, ] diff --git a/homeassistant/components/miele/binary_sensor.py b/homeassistant/components/miele/binary_sensor.py index 5eb9eccc5df..b43bd86010e 100644 --- a/homeassistant/components/miele/binary_sensor.py +++ b/homeassistant/components/miele/binary_sensor.py @@ -23,6 +23,8 @@ from .const import MieleAppliance from .coordinator import MieleConfigEntry from .entity import MieleEntity +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) @@ -263,13 +265,23 @@ async def async_setup_entry( ) -> None: """Set up the binary sensor platform.""" coordinator = config_entry.runtime_data + added_devices: set[str] = set() - 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 - ) + def _async_add_new_devices() -> None: + nonlocal added_devices + + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + 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_id in new_devices_set and device.device_type in definition.types + ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() class MieleBinarySensor(MieleEntity, BinarySensorEntity): diff --git a/homeassistant/components/miele/button.py b/homeassistant/components/miele/button.py index 70d4489e9be..4086c002743 100644 --- a/homeassistant/components/miele/button.py +++ b/homeassistant/components/miele/button.py @@ -17,6 +17,8 @@ from .const import DOMAIN, PROCESS_ACTION, MieleActions, MieleAppliance from .coordinator import MieleConfigEntry from .entity import MieleEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) @@ -111,13 +113,22 @@ async def async_setup_entry( ) -> None: """Set up the button platform.""" coordinator = config_entry.runtime_data + added_devices: set[str] = set() - 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 - ) + def _async_add_new_devices() -> None: + nonlocal added_devices + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + 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_id in new_devices_set and device.device_type in definition.types + ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() class MieleButton(MieleEntity, ButtonEntity): diff --git a/homeassistant/components/miele/climate.py b/homeassistant/components/miele/climate.py index 22257448e3a..24d020823c8 100644 --- a/homeassistant/components/miele/climate.py +++ b/homeassistant/components/miele/climate.py @@ -26,6 +26,8 @@ from .const import DEVICE_TYPE_TAGS, DISABLED_TEMP_ENTITIES, DOMAIN, MieleApplia from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator from .entity import MieleEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) @@ -131,16 +133,30 @@ async def async_setup_entry( ) -> None: """Set up the climate platform.""" coordinator = config_entry.runtime_data + added_devices: set[str] = set() - 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) + def _async_add_new_devices() -> None: + nonlocal added_devices + + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + 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_id in new_devices_set + and device.device_type in definition.types + and ( + definition.description.value_fn(device) + not in DISABLED_TEMP_ENTITIES + ) + ) ) - ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() class MieleClimate(MieleEntity, ClimateEntity): @@ -181,13 +197,13 @@ class MieleClimate(MieleEntity, ClimateEntity): self._attr_name = None if description.zone == 2: + t_key = "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" @@ -218,11 +234,11 @@ class MieleClimate(MieleEntity, ClimateEntity): 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 + self._device_id, + cast(float, kwargs.get(ATTR_TEMPERATURE)), + self.entity_description.zone, ) except aiohttp.ClientError as err: raise HomeAssistantError( diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 85934afae09..bda276c6d8a 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -2,6 +2,8 @@ from enum import IntEnum +from pymiele import MieleEnum + DOMAIN = "miele" MANUFACTURER = "Miele" @@ -9,6 +11,7 @@ ACTIONS = "actions" POWER_ON = "powerOn" POWER_OFF = "powerOff" PROCESS_ACTION = "processAction" +PROGRAM_ID = "programId" VENTILATION_STEP = "ventilationStep" TARGET_TEMPERATURE = "targetTemperature" AMBIENT_LIGHT = "ambientLight" @@ -246,6 +249,7 @@ STATE_PROGRAM_PHASE_OVEN = { } STATE_PROGRAM_PHASE_WARMING_DRAWER = { 0: "not_running", + 3073: "heating_up", 3075: "door_open", 3094: "keeping_warm", 3088: "cooling_down", @@ -295,7 +299,7 @@ STATE_PROGRAM_PHASE_ROBOT_VACUUM_CLEANER = { 5910: "remote_controlled", 65535: "not_running", } -STATE_PROGRAM_PHASE_MICROWAVE_OVEN_COMBO = { +STATE_PROGRAM_PHASE_STEAM_OVEN = { 0: "not_running", 3863: "steam_reduction", 7938: "process_running", @@ -312,25 +316,52 @@ STATE_PROGRAM_PHASE: dict[int, dict[int, str]] = { 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.WASHER_DRYER: STATE_PROGRAM_PHASE_WASHING_MACHINE + | 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.OVEN_MICROWAVE: STATE_PROGRAM_PHASE_MICROWAVE, + MieleAppliance.STEAM_OVEN: STATE_PROGRAM_PHASE_STEAM_OVEN, + MieleAppliance.STEAM_OVEN_COMBI: STATE_PROGRAM_PHASE_OVEN + | STATE_PROGRAM_PHASE_STEAM_OVEN, + MieleAppliance.STEAM_OVEN_MICRO: STATE_PROGRAM_PHASE_MICROWAVE + | STATE_PROGRAM_PHASE_STEAM_OVEN, + MieleAppliance.STEAM_OVEN_MK2: STATE_PROGRAM_PHASE_OVEN + | STATE_PROGRAM_PHASE_STEAM_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, + MieleAppliance.DISH_WARMER: STATE_PROGRAM_PHASE_WARMING_DRAWER, } -STATE_PROGRAM_TYPE = { - 0: "normal_operation_mode", - 1: "own_program", - 2: "automatic_program", - 3: "cleaning_care_program", - 4: "maintenance_program", -} + +class StateProgramType(MieleEnum): + """Defines program types.""" + + normal_operation_mode = 0 + own_program = 1 + automatic_program = 2 + cleaning_care_program = 3 + maintenance_program = 4 + missing2none = -9999 + + +class StateDryingStep(MieleEnum): + """Defines drying steps.""" + + extra_dry = 0 + normal_plus = 1 + normal = 2 + slightly_dry = 3 + hand_iron_1 = 4 + hand_iron_2 = 5 + machine_iron = 6 + smoothing = 7 + missing2none = -9999 + WASHING_MACHINE_PROGRAM_ID: dict[int, str] = { -1: "no_program", # Extrapolated from other device types. @@ -404,14 +435,33 @@ DISHWASHER_PROGRAM_ID: dict[int, str] = { 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", + 1: "automatic_plus", + 2: "cottons", + 3: "minimum_iron", + 4: "woollens_handcare", + 5: "delicates", + 6: "warm_air", + 7: "cool_air", + 8: "express", + 9: "cottons_eco", + 10: "gentle_smoothing", + 12: "proofing", + 13: "denim", + 14: "shirts", + 15: "sportswear", + 16: "outerwear", + 17: "silks_handcare", + 19: "standard_pillows", 20: "cottons", + 22: "basket_program", 23: "cottons_hygiene", + 24: "steam_smoothing", 30: "minimum_iron", - 31: "gentle_minimum_iron", + 31: "bed_linen", 40: "woollens_handcare", 50: "delicates", 60: "warm_air", + 66: "eco", 70: "cool_air", 80: "express", 90: "cottons", @@ -449,20 +499,151 @@ OVEN_PROGRAM_ID: dict[int, str] = { 31: "bottom_heat", 35: "moisture_plus_auto_roast", 40: "moisture_plus_fan_plus", + 48: "moisture_plus_auto_roast", + 49: "moisture_plus_fan_plus", + 50: "moisture_plus_intensive_bake", + 51: "moisture_plus_conventional_heat", 74: "moisture_plus_intensive_bake", 76: "moisture_plus_conventional_heat", - 49: "moisture_plus_fan_plus", + 97: "custom_program_1", + 98: "custom_program_2", + 99: "custom_program_3", + 100: "custom_program_4", + 101: "custom_program_5", + 102: "custom_program_6", + 103: "custom_program_7", + 104: "custom_program_8", + 105: "custom_program_9", + 106: "custom_program_10", + 107: "custom_program_11", + 108: "custom_program_12", + 109: "custom_program_13", + 110: "custom_program_14", + 111: "custom_program_15", + 112: "custom_program_16", + 113: "custom_program_17", + 114: "custom_program_18", + 115: "custom_program_19", + 116: "custom_program_20", + 323: "pyrolytic", + 326: "descale", + 327: "evaporate_water", + 335: "shabbat_program", + 336: "yom_tov", 356: "defrost", 357: "drying", 358: "heat_crockery", + 360: "low_temperature_cooking", 361: "steam_cooking", 362: "keeping_warm", - 512: "1_tray", - 513: "2_trays", - 529: "baking_tray", + 364: "apple_sponge", + 365: "apple_pie", + 367: "sponge_base", + 368: "swiss_roll", + 369: "butter_cake", + 373: "marble_cake", + 374: "fruit_streusel_cake", + 375: "madeira_cake", + 378: "blueberry_muffins", + 379: "walnut_muffins", + 382: "baguettes", + 383: "flat_bread", + 384: "plaited_loaf", + 385: "seeded_loaf", + 386: "white_bread_baking_tin", + 387: "white_bread_on_tray", + 394: "duck", + 396: "chicken_whole", + 397: "chicken_thighs", + 401: "turkey_whole", + 402: "turkey_drumsticks", + 406: "veal_fillet_roast", + 407: "veal_fillet_low_temperature_cooking", + 408: "veal_knuckle", + 409: "saddle_of_veal_roast", + 410: "saddle_of_veal_low_temperature_cooking", + 411: "braised_veal", + 415: "leg_of_lamb", + 419: "saddle_of_lamb_roast", + 420: "saddle_of_lamb_low_temperature_cooking", + 422: "beef_fillet_roast", + 423: "beef_fillet_low_temperature_cooking", + 427: "braised_beef", + 428: "roast_beef_roast", + 429: "roast_beef_low_temperature_cooking", + 435: "pork_smoked_ribs_roast", + 436: "pork_smoked_ribs_low_temperature_cooking", + 443: "ham_roast", + 449: "pork_fillet_roast", + 450: "pork_fillet_low_temperature_cooking", + 454: "saddle_of_venison", + 455: "rabbit", + 456: "saddle_of_roebuck", + 461: "salmon_fillet", + 464: "potato_cheese_gratin", + 486: "trout", + 491: "carp", + 492: "salmon_trout", + 496: "springform_tin_15cm", + 497: "springform_tin_20cm", + 498: "springform_tin_25cm", + 499: "fruit_flan_puff_pastry", + 500: "fruit_flan_short_crust_pastry", + 501: "sachertorte", + 502: "chocolate_hazlenut_cake_one_large", + 503: "chocolate_hazlenut_cake_several_small", + 504: "stollen", + 505: "drop_cookies_1_tray", + 506: "drop_cookies_2_trays", + 507: "linzer_augen_1_tray", + 508: "linzer_augen_2_trays", + 509: "almond_macaroons_1_tray", + 510: "almond_macaroons_2_trays", + 512: "biscuits_short_crust_pastry_1_tray", + 513: "biscuits_short_crust_pastry_2_trays", + 514: "vanilla_biscuits_1_tray", + 515: "vanilla_biscuits_2_trays", + 516: "choux_buns", + 518: "spelt_bread", + 519: "walnut_bread", + 520: "mixed_rye_bread", + 522: "dark_mixed_grain_bread", + 525: "multigrain_rolls", + 526: "rye_rolls", + 527: "white_rolls", + 528: "tart_flambe", + 529: "pizza_yeast_dough_baking_tray", + 530: "pizza_yeast_dough_round_baking_tine", + 531: "pizza_oil_cheese_dough_baking_tray", + 532: "pizza_oil_cheese_dough_round_baking_tine", + 533: "quiche_lorraine", + 534: "savoury_flan_puff_pastry", + 535: "savoury_flan_short_crust_pastry", + 536: "osso_buco", + 539: "beef_hash", + 543: "pork_with_crackling", + 550: "potato_gratin", + 551: "cheese_souffle", + 554: "baiser_one_large", + 555: "baiser_several_small", + 556: "lemon_meringue_pie", + 557: "viennese_apple_strudel", 621: "prove_15_min", 622: "prove_30_min", 623: "prove_45_min", + 624: "belgian_sponge_cake", + 625: "goose_unstuffed", + 634: "rack_of_lamb_with_vegetables", + 635: "yorkshire_pudding", + 636: "meat_loaf", + 695: "swiss_farmhouse_bread", + 696: "plaited_swiss_loaf", + 697: "tiger_bread", + 698: "ginger_loaf", + 699: "goose_stuffed", + 700: "beef_wellington", + 701: "pork_belly", + 702: "pikeperch_fillet_with_vegetables", 99001: "steam_bake", 17003: "no_program", } @@ -667,13 +848,33 @@ STEAM_OVEN_MICRO_PROGRAM_ID: dict[int, str] = { 72: "sous_vide", 75: "eco_steam_cooking", 77: "rapid_steam_cooking", + 97: "custom_program_1", + 98: "custom_program_2", + 99: "custom_program_3", + 100: "custom_program_4", + 101: "custom_program_5", + 102: "custom_program_6", + 103: "custom_program_7", + 104: "custom_program_8", + 105: "custom_program_9", + 106: "custom_program_10", + 107: "custom_program_11", + 108: "custom_program_12", + 109: "custom_program_13", + 110: "custom_program_14", + 111: "custom_program_15", + 112: "custom_program_16", + 113: "custom_program_17", + 114: "custom_program_18", + 115: "custom_program_19", + 116: "custom_program_20", 326: "descale", 330: "menu_cooking", 2018: "reheating_with_steam", 2019: "defrosting_with_steam", 2020: "blanching", 2021: "bottling", - 2022: "heat_crockery", + 2022: "sterilize_crockery", 2023: "prove_dough", 2027: "soak", 2029: "reheating_with_microwave", @@ -745,7 +946,7 @@ STEAM_OVEN_MICRO_PROGRAM_ID: dict[int, str] = { 2129: "potatoes_floury_diced", 2130: "german_turnip_sliced", 2131: "german_turnip_cut_into_batons", - 2132: "german_turnip_sliced", + 2132: "german_turnip_diced", 2133: "pumpkin_diced", 2134: "corn_on_the_cob", 2135: "mangel_cut", @@ -908,7 +1109,7 @@ STEAM_OVEN_MICRO_PROGRAM_ID: dict[int, str] = { 2429: "pumpkin_soup", 2430: "meat_with_rice", 2431: "beef_casserole", - 2450: "risotto", + 2450: "pumpkin_risotto", 2451: "risotto", 2453: "rice_pudding_steam_cooking", 2454: "rice_pudding_rapid_steam_cooking", @@ -1084,10 +1285,10 @@ STATE_PROGRAM_ID: dict[int, dict[int, str]] = { 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.OVEN_MICROWAVE: OVEN_PROGRAM_ID | STEAM_OVEN_MICRO_PROGRAM_ID, + MieleAppliance.STEAM_OVEN_MK2: OVEN_PROGRAM_ID | STEAM_OVEN_MICRO_PROGRAM_ID, + MieleAppliance.STEAM_OVEN_COMBI: OVEN_PROGRAM_ID | STEAM_OVEN_MICRO_PROGRAM_ID, + MieleAppliance.STEAM_OVEN: STEAM_OVEN_MICRO_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, diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py index 8902f0f173a..27456ffe04c 100644 --- a/homeassistant/components/miele/coordinator.py +++ b/homeassistant/components/miele/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio.timeouts +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging @@ -33,6 +34,11 @@ class MieleCoordinatorData: class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]): """Coordinator for Miele data.""" + config_entry: MieleConfigEntry + new_device_callbacks: list[Callable[[dict[str, MieleDevice]], None]] = [] + known_devices: set[str] = set() + devices: dict[str, MieleDevice] = {} + def __init__( self, hass: HomeAssistant, @@ -56,12 +62,20 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]): device_id: MieleDevice(device) for device_id, device in devices_json.items() } + self.devices = devices 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) + def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]: + """Add devices.""" + current_devices = set(self.devices) + new_devices: set[str] = current_devices - added_devices + + return (new_devices, current_devices) + async def callback_update_data(self, devices_json: dict[str, dict]) -> None: """Handle data update from the API.""" devices = { diff --git a/homeassistant/components/miele/fan.py b/homeassistant/components/miele/fan.py index fcd74a93bfb..5faaa46b33c 100644 --- a/homeassistant/components/miele/fan.py +++ b/homeassistant/components/miele/fan.py @@ -27,6 +27,8 @@ from .const import DOMAIN, POWER_OFF, POWER_ON, VENTILATION_STEP, MieleAppliance from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator from .entity import MieleEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) SPEED_RANGE = (1, 4) @@ -65,13 +67,22 @@ async def async_setup_entry( ) -> None: """Set up the fan platform.""" coordinator = config_entry.runtime_data + added_devices: set[str] = set() - 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 - ) + def _async_add_new_devices() -> None: + nonlocal added_devices + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + 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_id in new_devices_set and device.device_type in definition.types + ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() class MieleFan(MieleEntity, FanEntity): diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index a0fb1daaedd..1806fe688d6 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -32,6 +32,12 @@ "core_target_temperature": { "default": "mdi:thermometer-probe" }, + "target_temperature": { + "default": "mdi:thermometer-check" + }, + "drying_step": { + "default": "mdi:water-outline" + }, "program_id": { "default": "mdi:selection-ellipse-arrow-inside" }, @@ -47,11 +53,46 @@ "spin_speed": { "default": "mdi:sync" }, + "plate": { + "default": "mdi:circle-outline", + "state": { + "0": "mdi:circle-outline", + "110": "mdi:alpha-w-circle-outline", + "220": "mdi:alpha-w-circle-outline", + "1": "mdi:circle-slice-1", + "2": "mdi:circle-slice-1", + "3": "mdi:circle-slice-2", + "4": "mdi:circle-slice-2", + "5": "mdi:circle-slice-3", + "6": "mdi:circle-slice-3", + "7": "mdi:circle-slice-4", + "8": "mdi:circle-slice-4", + "9": "mdi:circle-slice-5", + "10": "mdi:circle-slice-5", + "11": "mdi:circle-slice-5", + "12": "mdi:circle-slice-6", + "13": "mdi:circle-slice-6", + "14": "mdi:circle-slice-6", + "15": "mdi:circle-slice-7", + "16": "mdi:circle-slice-7", + "17": "mdi:circle-slice-8", + "18": "mdi:circle-slice-8", + "117": "mdi:alpha-b-circle-outline", + "118": "mdi:alpha-b-circle-outline", + "217": "mdi:alpha-b-circle-outline" + } + }, "program_type": { "default": "mdi:state-machine" }, "remaining_time": { "default": "mdi:clock-end" + }, + "energy_forecast": { + "default": "mdi:lightning-bolt-outline" + }, + "water_forecast": { + "default": "mdi:water-outline" } }, "switch": { diff --git a/homeassistant/components/miele/light.py b/homeassistant/components/miele/light.py index 0fbc8124be8..e918b93b12a 100644 --- a/homeassistant/components/miele/light.py +++ b/homeassistant/components/miele/light.py @@ -23,6 +23,8 @@ from .const import AMBIENT_LIGHT, DOMAIN, LIGHT, LIGHT_OFF, LIGHT_ON, MieleAppli from .coordinator import MieleConfigEntry from .entity import MieleDevice, MieleEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) @@ -85,13 +87,22 @@ async def async_setup_entry( ) -> None: """Set up the light platform.""" coordinator = config_entry.runtime_data + added_devices: set[str] = set() - 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 - ) + def _async_add_new_devices() -> None: + nonlocal added_devices + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + 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_id in new_devices_set and device.device_type in definition.types + ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() class MieleLight(MieleEntity, LightEntity): diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json index c0795922875..c9a20e977f9 100644 --- a/homeassistant/components/miele/manifest.json +++ b/homeassistant/components/miele/manifest.json @@ -8,7 +8,7 @@ "iot_class": "cloud_push", "loggers": ["pymiele"], "quality_scale": "bronze", - "requirements": ["pymiele==0.4.3"], + "requirements": ["pymiele==0.5.2"], "single_config_entry": true, "zeroconf": ["_mieleathome._tcp.local."] } diff --git a/homeassistant/components/miele/quality_scale.yaml b/homeassistant/components/miele/quality_scale.yaml index e9d229c6a1b..94ce68278ef 100644 --- a/homeassistant/components/miele/quality_scale.yaml +++ b/homeassistant/components/miele/quality_scale.yaml @@ -32,45 +32,58 @@ rules: Handled by a setting in manifest.json as there is no account information in API # Silver - action-exceptions: todo + action-exceptions: + status: done + comment: No custom actions are defined config-entry-unloading: done docs-configuration-parameters: status: exempt comment: No configuration parameters - docs-installation-parameters: todo + docs-installation-parameters: + status: exempt + comment: | + Integration uses account linking via Nabu casa so no installation parameters are needed. entity-unavailable: done integration-owner: done - log-when-unavailable: todo - parallel-updates: - status: exempt - comment: Handled by coordinator + log-when-unavailable: + status: done + comment: Handled by DataUpdateCoordinator + parallel-updates: done reauthentication-flow: done test-coverage: todo # Gold - devices: todo - diagnostics: todo - discovery-update-info: todo - discovery: todo - docs-data-update: todo + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + Discovery is just used to initiate setup of the integration. No data from devices is collected. + discovery: done + docs-data-update: done docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done 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 + 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: todo - stale-devices: todo + repair-issues: + status: exempt + comment: | + No repair issues are created. + stale-devices: + status: done + comment: Stale devices can be deleted from GUI. Automatic deletion will have to wait until we get experience if devices are missing from API data intermittently. # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 0631d9c81dd..d5085ae606f 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -16,6 +16,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + PERCENTAGE, REVOLUTIONS_PER_MINUTE, EntityCategory, UnitOfEnergy, @@ -30,18 +31,71 @@ from homeassistant.helpers.typing import StateType from .const import ( STATE_PROGRAM_ID, STATE_PROGRAM_PHASE, - STATE_PROGRAM_TYPE, STATE_STATUS_TAGS, MieleAppliance, + StateDryingStep, + StateProgramType, StateStatus, ) from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator from .entity import MieleEntity +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) DISABLED_TEMPERATURE = -32768 +PLATE_POWERS = [ + "0", + "110", + "220", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "117", + "118", + "217", +] + + +DEFAULT_PLATE_COUNT = 4 + +PLATE_COUNT = { + "KM7678": 6, + "KM7697": 6, + "KM7878": 6, + "KM7897": 6, + "KMDA7633": 5, + "KMDA7634": 5, + "KMDA7774": 5, + "KMX": 6, +} + + +def _get_plate_count(tech_type: str) -> int: + """Get number of zones for hob.""" + stripped = tech_type.replace(" ", "") + for prefix, plates in PLATE_COUNT.items(): + if stripped.startswith(prefix): + return plates + return DEFAULT_PLATE_COUNT + def _convert_duration(value_list: list[int]) -> int | None: """Convert duration to minutes.""" @@ -53,7 +107,7 @@ class MieleSensorDescription(SensorEntityDescription): """Class describing Miele sensor entities.""" value_fn: Callable[[MieleDevice], StateType] - zone: int | None = None + zone: int = 1 @dataclass @@ -180,10 +234,10 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( description=MieleSensorDescription( key="state_program_type", translation_key="program_type", - value_fn=lambda value: value.state_program_type, + value_fn=lambda value: StateProgramType(value.state_program_type).name, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=sorted(set(STATE_PROGRAM_TYPE.values())), + options=sorted(set(StateProgramType.keys())), ), ), MieleSensorDefinition( @@ -205,6 +259,27 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( 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.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="energy_forecast", + translation_key="energy_forecast", + value_fn=( + lambda value: value.energy_forecast * 100 + if value.energy_forecast is not None + else None + ), + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), MieleSensorDefinition( types=( MieleAppliance.WASHING_MACHINE, @@ -221,6 +296,24 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( entity_category=EntityCategory.DIAGNOSTIC, ), ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="water_forecast", + translation_key="water_forecast", + value_fn=( + lambda value: value.water_forecast * 100 + if value.water_forecast is not None + else None + ), + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), MieleSensorDefinition( types=( MieleAppliance.WASHING_MACHINE, @@ -338,7 +431,6 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( ), description=MieleSensorDescription( key="state_temperature_1", - zone=1, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -381,11 +473,11 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( MieleAppliance.OVEN, MieleAppliance.OVEN_MICROWAVE, MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MK2, ), 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, @@ -397,6 +489,29 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( ), ), ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHER_DRYER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_target_temperature", + translation_key="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_target_temperature[0].temperature) + / 100.0 + ), + ), + ), MieleSensorDefinition( types=( MieleAppliance.OVEN, @@ -406,7 +521,6 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( 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, @@ -416,6 +530,42 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( ), ), ), + *( + MieleSensorDefinition( + types=( + MieleAppliance.HOB_HIGHLIGHT, + MieleAppliance.HOB_INDUCT_EXTR, + MieleAppliance.HOB_INDUCTION, + ), + description=MieleSensorDescription( + key="state_plate_step", + translation_key="plate", + translation_placeholders={"plate_no": str(i)}, + zone=i, + device_class=SensorDeviceClass.ENUM, + options=PLATE_POWERS, + value_fn=lambda value: value.state_plate_step[0].value_raw, + ), + ) + for i in range(1, 7) + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHER_DRYER, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + ), + description=MieleSensorDescription( + key="state_drying_step", + translation_key="drying_step", + value_fn=lambda value: StateDryingStep( + cast(int, value.state_drying_step) + ).name, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=sorted(StateDryingStep.keys()), + ), + ), ) @@ -426,35 +576,51 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data + added_devices: set[str] = set() - 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 + def _async_add_new_devices() -> None: + nonlocal added_devices + entities: list = [] + entity_class: type[MieleSensor] + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + for device_id, device in coordinator.data.devices.items(): + for definition in SENSOR_TYPES: if ( - definition.description.device_class == SensorDeviceClass.TEMPERATURE - and definition.description.value_fn(device) - == DISABLED_TEMPERATURE / 100 + device_id in new_devices_set + and device.device_type in definition.types ): - # Don't create entity if API signals that datapoint is disabled - continue - entities.append( - entity_class(coordinator, device_id, definition.description) - ) + 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_plate_step": + entity_class = MielePlateSensor + case _: + entity_class = MieleSensor + if ( + definition.description.device_class + == SensorDeviceClass.TEMPERATURE + and definition.description.value_fn(device) + == DISABLED_TEMPERATURE / 100 + ) or ( + definition.description.key == "state_plate_step" + and definition.description.zone + > _get_plate_count(device.tech_type) + ): + # 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) - async_add_entities(entities) + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() APPLIANCE_ICONS = { @@ -498,6 +664,33 @@ class MieleSensor(MieleEntity, SensorEntity): return self.entity_description.value_fn(self.device) +class MielePlateSensor(MieleSensor): + """Representation of a Sensor.""" + + entity_description: MieleSensorDescription + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSensorDescription, + ) -> None: + """Initialize the plate sensor.""" + super().__init__(coordinator, device_id, description) + self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}" + + @property + def native_value(self) -> StateType: + """Return the state of the plate sensor.""" + # state_plate_step is [] if all zones are off + plate_power = ( + self.device.state_plate_step[self.entity_description.zone - 1].value_raw + if self.device.state_plate_step + else 0 + ) + return str(plate_power) + + class MieleStatusSensor(MieleSensor): """Representation of the status sensor.""" @@ -552,22 +745,6 @@ class MielePhaseSensor(MieleSensor): ) -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.""" diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 032a214d442..cf01d01e476 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -13,6 +13,9 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Miele integration needs to re-authenticate your account" + }, + "oauth_discovery": { + "description": "Home Assistant has found a Miele device on your network. Press **Submit** to continue setting up Miele." } }, "abort": { @@ -91,10 +94,10 @@ "freezer": { "name": "Freezer" }, - "robot_vacuum_cleander": { + "robot_vacuum_cleaner": { "name": "Robot vacuum cleaner" }, - "steam_oven_microwave": { + "steam_oven_micro": { "name": "Steam oven micro" }, "dialog_oven": { @@ -191,6 +194,48 @@ "energy_consumption": { "name": "Energy consumption" }, + "plate": { + "name": "Plate {plate_no}", + "state": { + "0": "0", + "110": "Warming", + "220": "[%key:component::miele::entity::sensor::plate::state::110%]", + "1": "1", + "2": "1\u2022", + "3": "2", + "4": "2\u2022", + "5": "3", + "6": "3\u2022", + "7": "4", + "8": "4\u2022", + "9": "5", + "10": "5\u2022", + "11": "6", + "12": "6\u2022", + "13": "7", + "14": "7\u2022", + "15": "8", + "16": "8\u2022", + "17": "9", + "18": "9\u2022", + "117": "Boost", + "118": "[%key:component::miele::entity::sensor::plate::state::117%]", + "217": "[%key:component::miele::entity::sensor::plate::state::117%]" + } + }, + "drying_step": { + "name": "Drying step", + "state": { + "extra_dry": "Extra dry", + "hand_iron_1": "Hand iron 1", + "hand_iron_2": "Hand iron 2", + "machine_iron": "Machine iron", + "normal_plus": "Normal plus", + "normal": "Normal", + "slightly_dry": "Slightly dry", + "smoothing": "Smoothing" + } + }, "program_phase": { "name": "Program phase", "state": { @@ -288,9 +333,11 @@ "program_id": { "name": "Program", "state": { - "1_tray": "1 tray", - "2_trays": "2 trays", "amaranth": "Amaranth", + "almond_macaroons_1_tray": "Almond macaroons (1 tray)", + "almond_macaroons_2_trays": "Almond macaroons (2 trays)", + "apple_pie": "Apple pie", + "apple_sponge": "Apple sponge", "apples_diced": "Apples (diced)", "apples_halved": "Apples (halved)", "apples_quartered": "Apples (quartered)", @@ -302,6 +349,8 @@ "apricots_halved_steam_cooking": "Apricots (halved, steam cooking)", "apricots_quartered": "Apricots (quartered)", "apricots_wedges": "Apricots (wedges)", + "savoury_flan_puff_pastry": "Savoury flan, puff pastry", + "savoury_flan_short_crust_pastry": "Savoury flan, short crust pastry", "artichokes_large": "Artichokes large", "artichokes_medium": "Artichokes medium", "artichokes_small": "Artichokes small", @@ -311,13 +360,18 @@ "auto_roast": "Auto roast", "automatic": "Automatic", "automatic_plus": "Automatic plus", - "baking_tray": "Baking tray", + "baguettes": "Baguettes", "barista_assistant": "BaristaAssistant", + "baser_one_large": "Baiser (one large)", + "baser_severall_small": "Baiser (several small)", "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_fillet_low_temperature_cooking": "Beef fillet (low temperature cooking)", + "beef_fillet_roast": "Beef fillet (roast)", + "beef_hash": "Beef hash", "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)", @@ -325,9 +379,11 @@ "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)", + "beef_wellington": "Beef Wellington", "beetroot_whole_large": "Beetroot (whole, large)", "beetroot_whole_medium": "Beetroot (whole, medium)", "beetroot_whole_small": "Beetroot (whole, small)", + "belgian_sponge_cake": "Belgian sponge cake", "beluga_lentils": "Beluga lentils", "black_beans": "Black beans", "black_salsify_medium": "Black salsify (medium)", @@ -335,12 +391,15 @@ "black_salsify_thin": "Black salsify (thin)", "black_tea": "Black tea", "blanching": "Blanching", + "blueberry_muffins": "Blueberry muffins", "bologna_sausage": "Bologna sausage", "bottling": "Bottling", "bottling_hard": "Bottling (hard)", "bottling_medium": "Bottling (medium)", "bottling_soft": "Bottling (soft)", "bottom_heat": "Bottom heat", + "braised_beef": "Braised beef", + "braised_veal": "Braised veal", "bread_dumplings_boil_in_the_bag": "Bread dumplings (boil-in-the-bag)", "bread_dumplings_fresh": "Bread dumplings (fresh)", "brewing_unit_degrease": "Brewing unit degrease", @@ -363,6 +422,7 @@ "bunched_carrots_whole_large": "Bunched carrots (whole, large)", "bunched_carrots_whole_medium": "Bunched carrots (whole, medium)", "bunched_carrots_whole_small": "Bunched carrots (whole, small)", + "butter_cake": "Butter cake", "cafe_au_lait": "Café au lait", "caffe_latte": "Caffè latte", "cappuccino": "Cappuccino", @@ -391,13 +451,19 @@ "chanterelle": "Chanterelle", "char": "Char", "check_appliance": "Check appliance", + "cheese_souffle": "Cheese souffle", "cheesecake_one_large": "Cheesecake (one large)", "cheesecake_several_small": "Cheesecake (several small)", "chick_peas": "Chick peas", + "chicken_thighs": "Chicken thighs", "chicken_tikka_masala_with_rice": "Chicken Tikka Masala with rice", + "chicken_whole": "Chicken", "chinese_cabbage_cut": "Chinese cabbage (cut)", + "chocolate_hazlenut_cake_one_large": "Chocolate hazlenut cake (one large)", + "chocolate_hazlenut_cake_several_small": "Chocolate hazlenut cake (several small)", "chongming_rapid_steam_cooking": "Chongming (rapid steam cooking)", "chongming_steam_cooking": "Chongming (steam cooking)", + "choux_buns": "Choux buns", "christmas_pudding_cooking": "Christmas pudding (cooking)", "christmas_pudding_heating": "Christmas pudding (heating)", "clean_machine": "Clean machine", @@ -414,6 +480,8 @@ "common_sole_fillet_2_cm": "Common sole (fillet, 2 cm)", "conventional_heat": "Conventional heat", "cook_bacon": "Cook bacon", + "biscuits_short_crust_pastry_1_tray": "Biscuits, short crust pastry (1 tray)", + "biscuits_short_crust_pastry_2_trays": "Biscuits, short crust pastry (2 trays)", "cool_air": "Cool air", "corn_on_the_cob": "Corn on the cob", "cottons": "Cottons", @@ -424,7 +492,30 @@ "cranberries": "Cranberries", "crevettes": "Crevettes", "curtains": "Curtains", + "custom_program_1": "Custom program 1", + "custom_program_2": "Custom program 2", + "custom_program_3": "Custom program 3", + "custom_program_4": "Custom program 4", + "custom_program_5": "Custom program 5", + "custom_program_6": "Custom program 6", + "custom_program_7": "Custom program 7", + "custom_program_8": "Custom program 8", + "custom_program_9": "Custom program 9", + "custom_program_10": "Custom program 10", + "custom_program_11": "Custom program 11", + "custom_program_12": "Custom program 12", + "custom_program_13": "Custom program 13", + "custom_program_14": "Custom program 14", + "custom_program_15": "Custom program 15", + "custom_program_16": "Custom program 16", + "custom_program_17": "Custom program 17", + "custom_program_18": "Custom program 18", + "custom_program_19": "Custom program 19", + "custom_program_20": "Custom program 20", + "drop_cookies_1_tray": "Drop cookies (1 tray)", + "drop_cookies_2_trays": "Drop cookies (2 trays)", "dark_garments": "Dark garments", + "dark_mixed_grain_bread": "Dark mixed grain bread", "decrystallise_honey": "Decrystallise honey", "defrost": "Defrost", "defrosting_with_microwave": "Defrosting with microwave", @@ -437,6 +528,7 @@ "down_duvets": "Down duvets", "down_filled_items": "Down-filled items", "drain_spin": "Drain/spin", + "duck": "Duck", "dutch_hash": "Dutch hash", "eco": "ECO", "eco_40_60": "ECO 40-60", @@ -450,6 +542,7 @@ "endive_strips": "Endive (strips)", "espresso": "Espresso", "espresso_macchiato": "Espresso macchiato", + "evaporate_water": "Evaporate water", "express": "Express", "express_20": "Express 20'", "extra_quiet": "Extra quiet", @@ -459,8 +552,12 @@ "fennel_quartered": "Fennel (quartered)", "fennel_strips": "Fennel (strips)", "first_wash": "First wash", + "flat_bread": "Flat bread", "flat_white": "Flat white", "freshen_up": "Freshen up", + "fruit_streusel_cake": "Fruit streusel cake", + "fruit_flan_puff_pastry": "Fruit flan, puff pastry", + "fruit_flan_short_crust_pastry": "Fruit flan, short crust pastry", "fruit_tea": "Fruit tea", "full_grill": "Full grill", "gentle": "Gentle", @@ -468,12 +565,15 @@ "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)", + "german_turnip_diced": "German turnip (diced)", "gilt_head_bream_fillet": "Gilt-head bream (fillet)", "gilt_head_bream_whole": "Gilt-head bream (whole)", + "ginger_loaf": "Ginger loaf", "glasses_warm": "Glasses warm", "gnocchi_fresh": "Gnocchi (fresh)", "goose_barnacles": "Goose barnacles", + "goose_stuffed": "Goose stuffed", + "goose_unstuffed": "Goose unstuffed", "gooseberries": "Gooseberries", "goulash_soup": "Goulash soup", "green_asparagus_medium": "Green asparagus (medium)", @@ -489,7 +589,7 @@ "greenage_plums": "Greenage plums", "halibut_fillet_2_cm": "Halibut (fillet, 2 cm)", "halibut_fillet_3_cm": "Halibut (fillet, 3 cm)", - "heat_crockery": "Heat crockery", + "ham_roast": "Ham roast", "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)", @@ -529,15 +629,23 @@ "latte_macchiato": "Latte macchiato", "leek_pieces": "Leek (pieces)", "leek_rings": "Leek (rings)", + "leg_of_lamb": "Leg of lamb", + "lemon_meringue_pie": "Lemon meringue pie", + "linzer_augen_1_tray": "Linzer Augen (1 tray)", + "linzer_augen_2_trays": "Linzer Augen (2 trays)", "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)", + "low_temperature_cooking": "Low temperature cooking", "maintenance": "Maintenance program", + "madeira_cake": "Madeira cake", "make_yoghurt": "Make yoghurt", "mangel_cut": "Mangel (cut)", + "marble_cake": "Marble cake", "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_loaf": "Meat loaf", "meat_with_rice": "Meat with rice", "melt_chocolate": "Melt chocolate", "menu_cooking": "Menu cooking", @@ -548,10 +656,12 @@ "millet": "Millet", "minimum_iron": "Minimum iron", "mirabelles": "Mirabelles", + "mixed_rye_bread": "Mixed rye bread", "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", + "multigrain_rolls": "Multigrain rolls", "mushrooms_diced": "Mushrooms (diced)", "mushrooms_halved": "Mushrooms (halved)", "mushrooms_quartered": "Mushrooms (quartered)", @@ -569,6 +679,7 @@ "normal": "[%key:common::state::normal%]", "oats_cracked": "Oats (cracked)", "oats_whole": "Oats (whole)", + "osso_buco": "Osso buco", "outerwear": "Outerwear", "oyster_mushroom_diced": "Oyster mushroom (diced)", "oyster_mushroom_strips": "Oyster mushroom (strips)", @@ -609,11 +720,17 @@ "pike_piece": "Pike (piece)", "pillows": "Pillows", "pinto_beans": "Pinto beans", + "pizza_oil_cheese_dough_baking_tray": "Pizza, oil cheese dough (baking tray)", + "pizza_oil_cheese_dough_round_baking_tine": "Pizza, oil cheese dough (round baking tine)", + "pizza_yeast_dough_baking_tray": "Pizza, yeast dough (baking tray)", + "pizza_yeast_dough_round_baking_tine": "Pizza, yeast dough (round baking tine)", "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)", + "plaited_loaf": "Plaited loaf", + "plaited_swiss_loaf": "Plaited swiss loaf", "plums_halved": "Plums (halved)", "plums_whole": "Plums (whole)", "pointed_cabbage_cut": "Pointed cabbage (cut)", @@ -622,13 +739,21 @@ "polenta_swiss_style_fine_polenta": "Polenta Swiss style (fine polenta)", "polenta_swiss_style_medium_polenta": "Polenta Swiss style (medium polenta)", "popcorn": "Popcorn", + "pork_belly": "Pork belly", + "pork_fillet_low_temperature_cooking": "Pork fillet (low temperature cooking)", + "pork_fillet_roast": "Pork fillet (roast)", + "pork_smoked_ribs_low_temperature_cooking": "Pork smoked ribs (low temperature cooking)", + "pork_smoked_ribs_roast": "Pork smoked ribs (roast)", "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)", + "pork_with_crackling": "Pork with crackling", + "potato_cheese_gratin": "Potato cheese gratin", "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)", + "potato_gratin": "Potato gratin", "potatoes_floury_diced": "Potatoes (floury, diced)", "potatoes_floury_halved": "Potatoes (floury, halved)", "potatoes_floury_quartered": "Potatoes (floury, quartered)", @@ -669,11 +794,16 @@ "prove_45_min": "Prove for 45 min", "prove_dough": "Prove dough", "pumpkin_diced": "Pumpkin (diced)", + "pumpkin_risotto": "Pumpkin risotto", "pumpkin_soup": "Pumpkin soup", + "pyrolytic": "Pyrolytic", + "quiche_lorraine": "Quiche Lorraine", "quick_mw": "Quick MW", "quick_power_wash": "QuickPowerWash", "quinces_diced": "Quinces (diced)", "quinoa": "Quinoa", + "rabbit": "Rabbit", + "rack_of_lamb_with_vegetables": "Rack of lamb with vegetables", "rapid_steam_cooking": "Rapid steam cooking", "ravioli_fresh": "Ravioli (fresh)", "razor_clams_large": "Razor clams (large)", @@ -696,6 +826,8 @@ "rinse_out_lint": "Rinse out lint", "risotto": "Risotto", "ristretto": "Ristretto", + "roast_beef_low_temperature_cooking": "Roast beef (low temperature cooking)", + "roast_beef_roast": "Roast beef (roast)", "romanesco_florets_large": "Romanesco florets (large)", "romanesco_florets_medium": "Romanesco florets (medium)", "romanesco_florets_small": "Romanesco florets (small)", @@ -708,7 +840,16 @@ "runner_beans_sliced": "Runner beans (sliced)", "runner_beans_whole": "Runner beans (whole)", "rye_cracked": "Rye (cracked)", + "rye_rolls": "Rye rolls", "rye_whole": "Rye (whole)", + "sachertorte": "Sachertorte", + "saddle_of_lamb_low_temperature_cooking": "Saddle of lamb (low temperature cooking)", + "saddle_of_lamb_roast": "Saddle of lamb (roast)", + "saddle_of_roebuck": "Saddle of roebuck", + "saddle_of_veal_low_temperature_cooking": "Saddle of veal (low temperature cooking)", + "saddle_of_veal_roast": "Saddle of veal (roast)", + "saddle_of_venison": "Saddle of venison", + "salmon_fillet": "Salmon fillet", "salmon_fillet_2_cm": "Salmon (fillet, 2 cm)", "salmon_fillet_3_cm": "Salmon (fillet, 3 cm)", "salmon_piece": "Salmon (piece)", @@ -721,7 +862,9 @@ "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)", + "seeded_loaf": "Seeded loaf", "separate_rinse_starch": "Separate rinse/starch", + "shabbat_program": "Shabbat program", "sheyang_rapid_steam_cooking": "Sheyang (rapid steam cooking)", "sheyang_steam_cooking": "Sheyang (steam cooking)", "shirts": "Shirts", @@ -742,28 +885,39 @@ "sour_cherries": "Sour cherries", "sous_vide": "Sous-vide", "spaetzle_fresh": "Spätzle (fresh)", + "spelt_bread": "Spelt bread", "spelt_cracked": "Spelt (cracked)", "spelt_whole": "Spelt (whole)", "spinach": "Spinach", + "sponge_base": "Sponge base", "sportswear": "Sportswear", "spot": "Spot", + "springform_tin_15cm": "Springform tin 15cm", + "springform_tin_20cm": "Springform tin 20cm", + "springform_tin_25cm": "Springform tin 25cm", "standard_pillows": "Standard pillows", "starch": "Starch", "steam_care": "Steam care", "steam_cooking": "Steam cooking", "steam_smoothing": "Steam smoothing", + "sterilize_crockery": "Sterilize crockery", + "stollen": "Stollen", "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_farmhouse_bread": "Swiss farmhouse bread", + "swiss_roll": "Swiss roll", "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", + "tart_flambe": "Tart flambè", "teltow_turnip_diced": "Teltow turnip (diced)", "teltow_turnip_sliced": "Teltow turnip (sliced)", + "tiger_bread": "Tiger bread", "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)", @@ -781,16 +935,26 @@ "turbot_fillet_2_cm": "Turbot (fillet, 2 cm)", "turbot_fillet_3_cm": "Turbot (fillet, 3 cm)", "turkey_breast": "Turkey breast", + "turkey_drumsticks": "Turkey drumsticks", + "turkey_whole": "Turkey", "uonumma_koshihikari_rapid_steam_cooking": "Uonumma Koshihikari (rapid steam cooking)", "uonumma_koshihikari_steam_cooking": "Uonumma Koshihikari (steam cooking)", + "vanilla_biscuits_1_tray": "Vanilla biscuits (1 tray)", + "vanilla_biscuits_2_trays": "Vanilla biscuits (2 trays)", + "veal_fillet_low_temperature_cooking": "Veal fillet (low temperature 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_roast": "Veal fillet (roast)", "veal_fillet_whole": "Veal fillet (whole)", + "veal_knuckle": "Veal knuckle", "veal_sausages": "Veal sausages", "venus_clams": "Venus clams", "very_hot_water": "Very hot water", + "viennese_apple_strudel": "Viennese apple strudel", "viennese_silverside": "Viennese silverside", + "walnut_bread": "Walnut bread", + "walnut_muffins": "Walnut muffins", "warm_air": "Warm air", "wheat_cracked": "Wheat (cracked)", "wheat_whole": "Wheat (whole)", @@ -798,6 +962,9 @@ "white_asparagus_thick": "White asparagus (thick)", "white_asparagus_thin": "White asparagus (thin)", "white_beans": "White beans", + "white_bread_baking_tin": "White bread (baking tin)", + "white_bread_on_tray": "White bread (tray)", + "white_rolls": "White rolls", "white_tea": "White tea", "whole_ham_reheating": "Whole ham (reheating)", "whole_ham_steam_cooking": "Whole ham (steam cooking)", @@ -814,6 +981,8 @@ "yellow_beans_cut": "Yellow beans (cut)", "yellow_beans_whole": "Yellow beans (whole)", "yellow_split_peas": "Yellow split peas", + "yom_tov": "Yom tov", + "yorkshire_pudding": "Yorkshire pudding", "zander_fillet": "Zander (fillet)" } }, @@ -855,8 +1024,17 @@ "core_temperature": { "name": "Core temperature" }, + "target_temperature": { + "name": "Target temperature" + }, "core_target_temperature": { "name": "Core target temperature" + }, + "energy_forecast": { + "name": "Energy forecast" + }, + "water_forecast": { + "name": "Water forecast" } }, "switch": { diff --git a/homeassistant/components/miele/switch.py b/homeassistant/components/miele/switch.py index 427d90968b7..af46ef2c917 100644 --- a/homeassistant/components/miele/switch.py +++ b/homeassistant/components/miele/switch.py @@ -28,6 +28,8 @@ from .const import ( from .coordinator import MieleConfigEntry from .entity import MieleEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) @@ -116,22 +118,34 @@ async def async_setup_entry( ) -> None: """Set up the switch platform.""" coordinator = config_entry.runtime_data + added_devices: set[str] = set() - 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 + def _async_add_new_devices() -> None: + nonlocal added_devices + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices - entities.append( - entity_class(coordinator, device_id, definition.description) - ) - async_add_entities(entities) + entities = [] + for device_id, device in coordinator.data.devices.items(): + for definition in SWITCH_TYPES: + if ( + device_id in new_devices_set + and device.device_type in definition.types + ): + entity_class: type[MieleSwitch] = MieleSwitch + 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) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() class MieleSwitch(MieleEntity, SwitchEntity): diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py new file mode 100644 index 00000000000..29a89e39bdb --- /dev/null +++ b/homeassistant/components/miele/vacuum.py @@ -0,0 +1,226 @@ +"""Platform for Miele vacuum integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +import logging +from typing import Any, Final + +from aiohttp import ClientResponseError +from pymiele import MieleEnum + +from homeassistant.components.vacuum import ( + StateVacuumEntity, + StateVacuumEntityDescription, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, PROCESS_ACTION, PROGRAM_ID, MieleActions, MieleAppliance +from .coordinator import MieleConfigEntry +from .entity import MieleEntity + +PARALLEL_UPDATES = 1 + +_LOGGER = logging.getLogger(__name__) + +# The following const classes define program speeds and programs for the vacuum cleaner. +# Miele have used the same and overlapping names for fan_speeds and programs even +# if the contexts are different. This is an attempt to make it clearer in the integration. + + +class FanSpeed(IntEnum): + """Define fan speeds.""" + + normal = 0 + turbo = 1 + silent = 2 + + +class FanProgram(IntEnum): + """Define fan programs.""" + + auto = 1 + spot = 2 + turbo = 3 + silent = 4 + + +PROGRAM_MAP = { + "normal": FanProgram.auto, + "turbo": FanProgram.turbo, + "silent": FanProgram.silent, +} + +PROGRAM_TO_SPEED: dict[int, str] = { + FanProgram.auto: "normal", + FanProgram.turbo: "turbo", + FanProgram.silent: "silent", + FanProgram.spot: "normal", +} + + +class MieleVacuumStateCode(MieleEnum): + """Define vacuum state codes.""" + + idle = 0 + cleaning = 5889 + returning = 5890 + paused = 5891 + going_to_target_area = 5892 + wheel_lifted = 5893 + dirty_sensors = 5894 + dust_box_missing = 5895 + blocked_drive_wheels = 5896 + blocked_brushes = 5897 + check_dust_box_and_filter = 5898 + internal_fault_reboot = 5899 + blocked_front_wheel = 5900 + docked = 5903, 5904 + remote_controlled = 5910 + missing2none = -9999 + + +SUPPORTED_FEATURES = ( + VacuumEntityFeature.STATE + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.CLEAN_SPOT +) + + +@dataclass(frozen=True, kw_only=True) +class MieleVacuumDescription(StateVacuumEntityDescription): + """Class describing Miele vacuum entities.""" + + on_value: int + + +@dataclass +class MieleVacuumDefinition: + """Class for defining vacuum entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleVacuumDescription + + +VACUUM_TYPES: Final[tuple[MieleVacuumDefinition, ...]] = ( + MieleVacuumDefinition( + types=(MieleAppliance.ROBOT_VACUUM_CLEANER,), + description=MieleVacuumDescription( + key="vacuum", + on_value=14, + name=None, + translation_key="vacuum", + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the vacuum platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + MieleVacuum(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in VACUUM_TYPES + if device.device_type in definition.types + ) + + +VACUUM_PHASE_TO_ACTIVITY = { + MieleVacuumStateCode.idle.value: VacuumActivity.IDLE, + MieleVacuumStateCode.docked.value: VacuumActivity.DOCKED, + MieleVacuumStateCode.cleaning.value: VacuumActivity.CLEANING, + MieleVacuumStateCode.going_to_target_area.value: VacuumActivity.CLEANING, + MieleVacuumStateCode.returning.value: VacuumActivity.RETURNING, + MieleVacuumStateCode.wheel_lifted.value: VacuumActivity.ERROR, + MieleVacuumStateCode.dirty_sensors.value: VacuumActivity.ERROR, + MieleVacuumStateCode.dust_box_missing.value: VacuumActivity.ERROR, + MieleVacuumStateCode.blocked_drive_wheels.value: VacuumActivity.ERROR, + MieleVacuumStateCode.blocked_brushes.value: VacuumActivity.ERROR, + MieleVacuumStateCode.check_dust_box_and_filter.value: VacuumActivity.ERROR, + MieleVacuumStateCode.internal_fault_reboot.value: VacuumActivity.ERROR, + MieleVacuumStateCode.blocked_front_wheel.value: VacuumActivity.ERROR, + MieleVacuumStateCode.paused.value: VacuumActivity.PAUSED, + MieleVacuumStateCode.remote_controlled.value: VacuumActivity.PAUSED, +} + + +class MieleVacuum(MieleEntity, StateVacuumEntity): + """Representation of a Vacuum entity.""" + + entity_description: MieleVacuumDescription + _attr_supported_features = SUPPORTED_FEATURES + _attr_fan_speed_list = [fan_speed.name for fan_speed in FanSpeed] + _attr_name = None + + @property + def activity(self) -> VacuumActivity | None: + """Return activity.""" + return VACUUM_PHASE_TO_ACTIVITY.get( + MieleVacuumStateCode(self.device.state_program_phase).value + ) + + @property + def battery_level(self) -> int | None: + """Return the battery level.""" + return self.device.state_battery_level + + @property + def fan_speed(self) -> str | None: + """Return the fan speed.""" + return PROGRAM_TO_SPEED.get(self.device.state_program_id) + + @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 send(self, device_id: str, action: dict[str, Any]) -> None: + """Send action to the device.""" + try: + await self.api.send_action(device_id, action) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + + async def async_clean_spot(self, **kwargs: Any) -> None: + """Clean spot.""" + await self.send(self._device_id, {PROGRAM_ID: FanProgram.spot}) + + async def async_start(self, **kwargs: Any) -> None: + """Start cleaning.""" + await self.send(self._device_id, {PROCESS_ACTION: MieleActions.START}) + + async def async_stop(self, **kwargs: Any) -> None: + """Stop cleaning.""" + await self.send(self._device_id, {PROCESS_ACTION: MieleActions.STOP}) + + async def async_pause(self, **kwargs: Any) -> None: + """Pause cleaning.""" + await self.send(self._device_id, {PROCESS_ACTION: MieleActions.PAUSE}) + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + await self.send(self._device_id, {PROGRAM_ID: PROGRAM_MAP[fan_speed]}) diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index 3155d83a736..8eb556319f9 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -6,7 +6,7 @@ import logging from dns.resolver import LifetimeTimeout from mcstatus import BedrockServer, JavaServer -from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index be399a3c8dc..f68586f1992 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], "quality_scale": "silver", - "requirements": ["mcstatus==11.1.1"] + "requirements": ["mcstatus==12.0.1"] } diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 7e5a0a291b6..707a0215f2f 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -1,5 +1,7 @@ """Device tracker for Mobile app.""" +from typing import Any + from homeassistant.components.device_tracker import ( ATTR_BATTERY, ATTR_GPS, @@ -15,6 +17,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ) 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 homeassistant.helpers.restore_state import RestoreEntity @@ -52,17 +55,17 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): self._dispatch_unsub = None @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID.""" return self._entry.data[ATTR_DEVICE_ID] @property - def battery_level(self): + def battery_level(self) -> int | None: """Return the battery level of the device.""" return self._data.get(ATTR_BATTERY) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific attributes.""" attrs = {} for key in ATTR_KEYS: @@ -72,12 +75,12 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): return attrs @property - def location_accuracy(self): + def location_accuracy(self) -> float: """Return the gps accuracy of the device.""" - return self._data.get(ATTR_GPS_ACCURACY) + return self._data.get(ATTR_GPS_ACCURACY, 0) @property - def latitude(self): + def latitude(self) -> float | None: """Return latitude value of the device.""" if (gps := self._data.get(ATTR_GPS)) is None: return None @@ -85,7 +88,7 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): return gps[0] @property - def longitude(self): + def longitude(self) -> float | None: """Return longitude value of the device.""" if (gps := self._data.get(ATTR_GPS)) is None: return None @@ -93,19 +96,19 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): return gps[1] @property - def location_name(self): + def location_name(self) -> str | None: """Return a location name for the current location of the device.""" if location_name := self._data.get(ATTR_LOCATION_NAME): return location_name return None @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._entry.data[ATTR_DEVICE_NAME] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" return device_info(self._entry.data) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 52642cc32e3..ab387030af8 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -62,8 +62,10 @@ from .const import ( CALL_TYPE_X_COILS, CALL_TYPE_X_REGISTER_HOLDINGS, CONF_BAUDRATE, + CONF_BRIGHTNESS_REGISTER, CONF_BYTESIZE, CONF_CLIMATES, + CONF_COLOR_TEMP_REGISTER, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_FAN_MODE_AUTO, @@ -415,7 +417,14 @@ SWITCH_SCHEMA = BASE_SWITCH_SCHEMA.extend( } ) -LIGHT_SCHEMA = BASE_SWITCH_SCHEMA.extend({}) +LIGHT_SCHEMA = BASE_SWITCH_SCHEMA.extend( + { + vol.Optional(CONF_BRIGHTNESS_REGISTER): cv.positive_int, + vol.Optional(CONF_COLOR_TEMP_REGISTER): cv.positive_int, + vol.Optional(CONF_MIN_TEMP): cv.positive_int, + vol.Optional(CONF_MAX_TEMP): cv.positive_int, + } +) FAN_SCHEMA = BASE_SWITCH_SCHEMA.extend({}) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 634637a6b08..068a46b1f81 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -16,6 +16,8 @@ from homeassistant.const import ( CONF_BAUDRATE = "baudrate" CONF_BYTESIZE = "bytesize" CONF_CLIMATES = "climates" +CONF_BRIGHTNESS_REGISTER = "brightness_address" +CONF_COLOR_TEMP_REGISTER = "color_temp_address" CONF_DATA_TYPE = "data_type" CONF_DEVICE_ADDRESS = "device_address" CONF_FANS = "fans" @@ -167,3 +169,11 @@ PLATFORMS = ( (Platform.SENSOR, CONF_SENSORS), (Platform.SWITCH, CONF_SWITCHES), ) + +LIGHT_DEFAULT_MIN_KELVIN = 2000 +LIGHT_DEFAULT_MAX_KELVIN = 7000 +LIGHT_MIN_BRIGHTNESS = 0 +LIGHT_MAX_BRIGHTNESS = 255 +LIGHT_MODBUS_SCALE_MIN = 0 +LIGHT_MODBUS_SCALE_MAX = 100 +LIGHT_MODBUS_INVALID_VALUE = 0xFFFF diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 4684c2f2b8a..53c3e8f8709 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -285,10 +285,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): v_result = [] for entry in val: v_temp = self.__process_raw_value(entry) - if v_temp is None: - v_result.append("0") - else: + if self._data_type != DataType.CUSTOM: v_result.append(str(v_temp)) + else: + v_result.append(str(v_temp) if v_temp is not None else "0") return ",".join(map(str, v_result)) # Apply scale, precision, limits to floats and ints diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index ce1c881733e..c025eefe0e4 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -2,18 +2,40 @@ from __future__ import annotations +import logging from typing import Any -from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ColorMode, + LightEntity, +) from homeassistant.const import CONF_LIGHTS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub +from .const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_WRITE_REGISTER, + CONF_BRIGHTNESS_REGISTER, + CONF_COLOR_TEMP_REGISTER, + CONF_MAX_TEMP, + CONF_MIN_TEMP, + LIGHT_DEFAULT_MAX_KELVIN, + LIGHT_DEFAULT_MIN_KELVIN, + LIGHT_MAX_BRIGHTNESS, + LIGHT_MODBUS_INVALID_VALUE, + LIGHT_MODBUS_SCALE_MAX, + LIGHT_MODBUS_SCALE_MIN, +) from .entity import BaseSwitch +from .modbus import ModbusHub PARALLEL_UPDATES = 1 +_LOGGER = logging.getLogger(__name__) async def async_setup_platform( @@ -32,9 +54,176 @@ async def async_setup_platform( class ModbusLight(BaseSwitch, LightEntity): """Class representing a Modbus light.""" - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} + def __init__( + self, hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any] + ) -> None: + """Initialize the Modbus light entity.""" + super().__init__(hass, hub, config) + self._brightness_address: int | None = config.get(CONF_BRIGHTNESS_REGISTER) + self._color_temp_address: int | None = config.get(CONF_COLOR_TEMP_REGISTER) + + # Determine color mode dynamically + self._attr_color_mode = self._detect_color_mode(config) + self._attr_supported_color_modes = {self._attr_color_mode} + + # Set min/max kelvin values if the mode is COLOR_TEMP + if self._attr_color_mode == ColorMode.COLOR_TEMP: + self._attr_min_color_temp_kelvin = config.get( + CONF_MIN_TEMP, LIGHT_DEFAULT_MIN_KELVIN + ) + self._attr_max_color_temp_kelvin = config.get( + CONF_MAX_TEMP, LIGHT_DEFAULT_MAX_KELVIN + ) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is None: + return + + if (brightness := state.attributes.get(ATTR_BRIGHTNESS)) is not None: + self._attr_brightness = brightness + + if (color_temp := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + self._attr_color_temp_kelvin = color_temp + + @staticmethod + def _detect_color_mode(config: dict[str, Any]) -> ColorMode: + """Determine the appropriate color mode for the light based on configuration.""" + if CONF_COLOR_TEMP_REGISTER in config: + return ColorMode.COLOR_TEMP + if CONF_BRIGHTNESS_REGISTER in config: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF async def async_turn_on(self, **kwargs: Any) -> None: - """Set light on.""" + """Turn light on and set brightness if provided.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness and isinstance(brightness, int): + await self.async_set_brightness(brightness) + color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + if color_temp and isinstance(color_temp, int): + await self.async_set_color_temp(color_temp) await self.async_turn(self.command_on) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + await self.async_turn(self._command_off) + + async def async_set_brightness(self, brightness: int) -> None: + """Set the brightness of the light.""" + if not self._brightness_address: + return + + conv_brightness = self._convert_brightness_to_modbus(brightness) + + await self._hub.async_pb_call( + unit=self._slave, + address=self._brightness_address, + value=conv_brightness, + use_call=CALL_TYPE_WRITE_REGISTER, + ) + if not self._verify_active: + self._attr_brightness = brightness + + async def async_set_color_temp(self, color_temp_kelvin: int) -> None: + """Send Modbus command to set color temperature.""" + if not self._color_temp_address: + return + + conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin) + + await self._hub.async_pb_call( + unit=self._slave, + address=self._color_temp_address, + value=conv_color_temp_kelvin, + use_call=CALL_TYPE_WRITE_REGISTER, + ) + if not self._verify_active: + self._attr_color_temp_kelvin = color_temp_kelvin + + async def _async_update(self) -> None: + """Update the entity state, including brightness and color temperature.""" + await super()._async_update() + + if not self._verify_active: + return + + if self._brightness_address: + brightness_result = await self._hub.async_pb_call( + unit=self._slave, + value=1, + address=self._brightness_address, + use_call=CALL_TYPE_REGISTER_HOLDING, + ) + + if ( + brightness_result + and brightness_result.registers + and brightness_result.registers[0] != LIGHT_MODBUS_INVALID_VALUE + ): + self._attr_brightness = self._convert_modbus_percent_to_brightness( + brightness_result.registers[0] + ) + + if self._color_temp_address: + color_result = await self._hub.async_pb_call( + unit=self._slave, + value=1, + address=self._color_temp_address, + use_call=CALL_TYPE_REGISTER_HOLDING, + ) + if ( + color_result + and color_result.registers + and color_result.registers[0] != LIGHT_MODBUS_INVALID_VALUE + ): + self._attr_color_temp_kelvin = ( + self._convert_modbus_percent_to_temperature( + color_result.registers[0] + ) + ) + + @staticmethod + def _convert_modbus_percent_to_brightness(percent: int) -> int: + """Convert Modbus scale (0-100) to the brightness (0-255).""" + return round( + percent + / (LIGHT_MODBUS_SCALE_MAX - LIGHT_MODBUS_SCALE_MIN) + * LIGHT_MAX_BRIGHTNESS + ) + + def _convert_modbus_percent_to_temperature(self, percent: int) -> int: + """Convert Modbus scale (0-100) to the color temperature in Kelvin (2000-7000 К).""" + assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance( + self._attr_max_color_temp_kelvin, int + ) + return round( + self._attr_min_color_temp_kelvin + + ( + percent + / (LIGHT_MODBUS_SCALE_MAX - LIGHT_MODBUS_SCALE_MIN) + * (self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin) + ) + ) + + @staticmethod + def _convert_brightness_to_modbus(brightness: int) -> int: + """Convert brightness (0-255) to Modbus scale (0-100).""" + return round( + brightness + / LIGHT_MAX_BRIGHTNESS + * (LIGHT_MODBUS_SCALE_MAX - LIGHT_MODBUS_SCALE_MIN) + ) + + def _convert_color_temp_to_modbus(self, kelvin: int) -> int: + """Convert color temperature from Kelvin to the Modbus scale (0-100).""" + assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance( + self._attr_max_color_temp_kelvin, int + ) + return round( + LIGHT_MODBUS_SCALE_MIN + + (kelvin - self._attr_min_color_temp_kelvin) + * (LIGHT_MODBUS_SCALE_MAX - LIGHT_MODBUS_SCALE_MIN) + / (self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin) + ) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 2c2efb70d5a..490aece587c 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -73,7 +73,9 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): super().__init__(hass, hub, entry) if slave_count: self._count = self._count * (slave_count + 1) - self._coordinator: DataUpdateCoordinator[list[float] | None] | None = None + self._coordinator: DataUpdateCoordinator[list[float | None] | None] | None = ( + None + ) self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = entry.get(CONF_STATE_CLASS) self._attr_device_class = entry.get(CONF_DEVICE_CLASS) @@ -120,37 +122,45 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): self._coordinator.async_set_updated_data(None) self.async_write_ha_state() return - + self._attr_available = True result = self.unpack_structure_result(raw_result.registers) if self._coordinator: + result_array: list[float | None] = [] if result: - result_array = list( - map( - float if not self._value_is_int else int, - result.split(","), - ) - ) + for i in result.split(","): + if i != "None": + result_array.append( + float(i) if not self._value_is_int else int(i) + ) + else: + result_array.append(None) + self._attr_native_value = result_array[0] self._coordinator.async_set_updated_data(result_array) else: self._attr_native_value = None - self._coordinator.async_set_updated_data(None) + result_array = (self._slave_count + 1) * [None] + self._coordinator.async_set_updated_data(result_array) else: self._attr_native_value = result - self._attr_available = self._attr_native_value is not None self.async_write_ha_state() class SlaveSensor( - CoordinatorEntity[DataUpdateCoordinator[list[float] | None]], + CoordinatorEntity[DataUpdateCoordinator[list[float | None] | None]], RestoreSensor, SensorEntity, ): """Modbus slave register sensor.""" + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._attr_available + def __init__( self, - coordinator: DataUpdateCoordinator[list[float] | None], + coordinator: DataUpdateCoordinator[list[float | None] | None], idx: int, entry: dict[str, Any], ) -> None: @@ -178,4 +188,5 @@ class SlaveSensor( """Handle updated data from the coordinator.""" result = self.coordinator.data self._attr_native_value = result[self._idx] if result else None + self._attr_available = result is not None super()._handle_coordinator_update() diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index dbf43e3d30f..165c4c19675 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -51,6 +51,7 @@ POSITION_DEVICE_MAP = { BlindType.CurtainRight: CoverDeviceClass.CURTAIN, BlindType.SkylightBlind: CoverDeviceClass.SHADE, BlindType.InsectScreen: CoverDeviceClass.SHADE, + BlindType.RadioReceiver: CoverDeviceClass.SHADE, } TILT_DEVICE_MAP = { diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 1654d5b5937..1a6c9c5f82f 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.26"] + "requirements": ["motionblinds==0.6.27"] } diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index a1e146d4e36..0ac3cb7f786 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -35,7 +35,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_STATE_TOPIC, PAYLOAD_NONE +from .const import CONF_OFF_DELAY, CONF_STATE_TOPIC, PAYLOAD_NONE from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -45,7 +45,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 DEFAULT_NAME = "MQTT Binary sensor" -CONF_OFF_DELAY = "off_delay" DEFAULT_PAYLOAD_OFF = "OFF" DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 5b2bcc8920f..f5821896071 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -14,7 +14,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA -from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_RETAIN +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_PAYLOAD_PRESS, + CONF_RETAIN, + DEFAULT_PAYLOAD_PRESS, +) from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -22,9 +28,7 @@ from .util import valid_publish_topic PARALLEL_UPDATES = 0 -CONF_PAYLOAD_PRESS = "payload_press" DEFAULT_NAME = "MQTT Button" -DEFAULT_PAYLOAD_PRESS = "PRESS" PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index f6f53599363..c2bcb306d0b 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -839,9 +839,9 @@ class MQTT: """Return a string with the exception message.""" # if msg_callback is a partial we return the name of the first argument if isinstance(msg_callback, partial): - call_back_name = getattr(msg_callback.args[0], "__name__") + call_back_name = msg_callback.args[0].__name__ else: - call_back_name = getattr(msg_callback, "__name__") + call_back_name = msg_callback.__name__ return ( f"Exception in {call_back_name} when handling msg on " f"'{msg.topic}': '{msg.payload}'" # type: ignore[str-bytes-safe] @@ -1109,7 +1109,7 @@ class MQTT: # decoding the same topic multiple times. topic = msg.topic except UnicodeDecodeError: - bare_topic: bytes = getattr(msg, "_topic") + bare_topic: bytes = msg._topic # noqa: SLF001 _LOGGER.warning( "Skipping received%s message on invalid topic %s (qos=%s): %s", " retained" if msg.retain else "", diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 4445462003f..b41e549093d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -25,6 +25,9 @@ from cryptography.hazmat.primitives.serialization import ( from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.button import ButtonDeviceClass +from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.components.light import ( @@ -36,6 +39,7 @@ from homeassistant.components.light import ( from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_UNITS, + STATE_CLASS_UNITS, SensorDeviceClass, SensorStateClass, ) @@ -76,6 +80,10 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section @@ -136,6 +144,10 @@ from .const import ( CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_DIRECTION_COMMAND_TEMPLATE, + CONF_DIRECTION_COMMAND_TOPIC, + CONF_DIRECTION_STATE_TOPIC, + CONF_DIRECTION_VALUE_TEMPLATE, CONF_DISCOVERY_PREFIX, CONF_EFFECT_COMMAND_TEMPLATE, CONF_EFFECT_COMMAND_TOPIC, @@ -148,6 +160,8 @@ from .const import ( CONF_FLASH, CONF_FLASH_TIME_LONG, CONF_FLASH_TIME_SHORT, + CONF_GET_POSITION_TEMPLATE, + CONF_GET_POSITION_TOPIC, CONF_GREEN_TEMPLATE, CONF_HS_COMMAND_TEMPLATE, CONF_HS_COMMAND_TOPIC, @@ -157,10 +171,35 @@ from .const import ( CONF_LAST_RESET_VALUE_TEMPLATE, CONF_MAX_KELVIN, CONF_MIN_KELVIN, + CONF_OFF_DELAY, CONF_ON_COMMAND_TYPE, CONF_OPTIONS, + CONF_OSCILLATION_COMMAND_TEMPLATE, + CONF_OSCILLATION_COMMAND_TOPIC, + CONF_OSCILLATION_STATE_TOPIC, + CONF_OSCILLATION_VALUE_TEMPLATE, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_CLOSE, CONF_PAYLOAD_NOT_AVAILABLE, + CONF_PAYLOAD_OPEN, + CONF_PAYLOAD_OSCILLATION_OFF, + CONF_PAYLOAD_OSCILLATION_ON, + CONF_PAYLOAD_PRESS, + CONF_PAYLOAD_RESET_PERCENTAGE, + CONF_PAYLOAD_RESET_PRESET_MODE, + CONF_PAYLOAD_STOP, + CONF_PAYLOAD_STOP_TILT, + CONF_PERCENTAGE_COMMAND_TEMPLATE, + CONF_PERCENTAGE_COMMAND_TOPIC, + CONF_PERCENTAGE_STATE_TOPIC, + CONF_PERCENTAGE_VALUE_TEMPLATE, + CONF_POSITION_CLOSED, + CONF_POSITION_OPEN, + CONF_PRESET_MODE_COMMAND_TEMPLATE, + CONF_PRESET_MODE_COMMAND_TOPIC, + CONF_PRESET_MODE_STATE_TOPIC, + CONF_PRESET_MODE_VALUE_TEMPLATE, + CONF_PRESET_MODES_LIST, CONF_QOS, CONF_RED_TEMPLATE, CONF_RETAIN, @@ -177,10 +216,30 @@ from .const import ( CONF_RGBWW_STATE_TOPIC, CONF_RGBWW_VALUE_TEMPLATE, CONF_SCHEMA, + CONF_SET_POSITION_TEMPLATE, + CONF_SET_POSITION_TOPIC, + CONF_SPEED_RANGE_MAX, + CONF_SPEED_RANGE_MIN, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OFF, + CONF_STATE_ON, + CONF_STATE_OPEN, + CONF_STATE_OPENING, + CONF_STATE_STOPPED, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, CONF_SUGGESTED_DISPLAY_PRECISION, CONF_SUPPORTED_COLOR_MODES, + CONF_TILT_CLOSED_POSITION, + CONF_TILT_COMMAND_TEMPLATE, + CONF_TILT_COMMAND_TOPIC, + CONF_TILT_MAX, + CONF_TILT_MIN, + CONF_TILT_OPEN_POSITION, + CONF_TILT_STATE_OPTIMISTIC, + CONF_TILT_STATUS_TEMPLATE, + CONF_TILT_STATUS_TOPIC, CONF_TLS_INSECURE, CONF_TRANSITION, CONF_TRANSPORT, @@ -201,13 +260,29 @@ from .const import ( DEFAULT_KEEPALIVE, DEFAULT_ON_COMMAND_TYPE, DEFAULT_PAYLOAD_AVAILABLE, + DEFAULT_PAYLOAD_CLOSE, DEFAULT_PAYLOAD_NOT_AVAILABLE, DEFAULT_PAYLOAD_OFF, DEFAULT_PAYLOAD_ON, + DEFAULT_PAYLOAD_OPEN, + DEFAULT_PAYLOAD_OSCILLATE_OFF, + DEFAULT_PAYLOAD_OSCILLATE_ON, + DEFAULT_PAYLOAD_PRESS, + DEFAULT_PAYLOAD_RESET, + DEFAULT_PAYLOAD_STOP, DEFAULT_PORT, + DEFAULT_POSITION_CLOSED, + DEFAULT_POSITION_OPEN, DEFAULT_PREFIX, DEFAULT_PROTOCOL, DEFAULT_QOS, + DEFAULT_SPEED_RANGE_MAX, + DEFAULT_SPEED_RANGE_MIN, + DEFAULT_STATE_STOPPED, + DEFAULT_TILT_CLOSED_POSITION, + DEFAULT_TILT_MAX, + DEFAULT_TILT_MIN, + DEFAULT_TILT_OPEN_POSITION, DEFAULT_TRANSPORT, DEFAULT_WILL, DEFAULT_WS_PATH, @@ -305,7 +380,16 @@ KEY_UPLOAD_SELECTOR = FileSelector( ) # Subentry selectors -SUBENTRY_PLATFORMS = [Platform.LIGHT, Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH] +SUBENTRY_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.NOTIFY, + Platform.SENSOR, + Platform.SWITCH, +] SUBENTRY_PLATFORM_SELECTOR = SelectSelector( SelectSelectorConfig( options=[platform.value for platform in SUBENTRY_PLATFORMS], @@ -337,6 +421,30 @@ SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( sort=True, ) ) +BINARY_SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in BinarySensorDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_binary_sensor", + sort=True, + ) +) +BUTTON_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in ButtonDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_button", + sort=True, + ) +) +COVER_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in CoverDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_cover", + sort=True, + ) +) SENSOR_STATE_CLASS_SELECTOR = SelectSelector( SelectSelectorConfig( options=[device_class.value for device_class in SensorStateClass], @@ -354,10 +462,24 @@ OPTIONS_SELECTOR = SelectSelector( SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector( NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9) ) -EXPIRE_AFTER_SELECTOR = NumberSelector( +TIMEOUT_SELECTOR = NumberSelector( NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0) ) +# Cover specific selectors +POSITION_SELECTOR = NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX)) + +# Fan specific selectors +FAN_SPEED_RANGE_MIN_SELECTOR = vol.All( + NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1)), + vol.Coerce(int), +) +FAN_SPEED_RANGE_MAX_SELECTOR = vol.All( + NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=2)), + vol.Coerce(int), +) +PRESET_MODES_SELECTOR = OPTIONS_SELECTOR + # Switch specific selectors SWITCH_DEVICE_CLASS_SELECTOR = SelectSelector( SelectSelectorConfig( @@ -416,6 +538,71 @@ SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( ) +@callback +def validate_cover_platform_config( + config: dict[str, Any], +) -> dict[str, str]: + """Validate the cover platform options.""" + errors: dict[str, str] = {} + + # If set position topic is set then get position topic is set as well. + if CONF_SET_POSITION_TOPIC in config and CONF_GET_POSITION_TOPIC not in config: + errors["cover_position_settings"] = ( + "cover_get_and_set_position_must_be_set_together" + ) + + # if templates are set make sure the topic for the template is also set + if CONF_VALUE_TEMPLATE in config and CONF_STATE_TOPIC not in config: + errors[CONF_VALUE_TEMPLATE] = ( + "cover_value_template_must_be_used_with_state_topic" + ) + + if CONF_GET_POSITION_TEMPLATE in config and CONF_GET_POSITION_TOPIC not in config: + errors["cover_position_settings"] = ( + "cover_get_position_template_must_be_used_with_get_position_topic" + ) + + if CONF_SET_POSITION_TEMPLATE in config and CONF_SET_POSITION_TOPIC not in config: + errors["cover_position_settings"] = ( + "cover_set_position_template_must_be_used_with_set_position_topic" + ) + + if CONF_TILT_COMMAND_TEMPLATE in config and CONF_TILT_COMMAND_TOPIC not in config: + errors["cover_tilt_settings"] = ( + "cover_tilt_command_template_must_be_used_with_tilt_command_topic" + ) + + if CONF_TILT_STATUS_TEMPLATE in config and CONF_TILT_STATUS_TOPIC not in config: + errors["cover_tilt_settings"] = ( + "cover_tilt_status_template_must_be_used_with_tilt_status_topic" + ) + + return errors + + +@callback +def validate_fan_platform_config(config: dict[str, Any]) -> dict[str, str]: + """Validate the fan config options.""" + errors: dict[str, str] = {} + if ( + CONF_SPEED_RANGE_MIN in config + and CONF_SPEED_RANGE_MAX in config + and config[CONF_SPEED_RANGE_MIN] >= config[CONF_SPEED_RANGE_MAX] + ): + errors["fan_speed_settings"] = ( + "fan_speed_range_max_must_be_greater_than_speed_range_min" + ) + if ( + CONF_PRESET_MODES_LIST in config + and config.get(CONF_PAYLOAD_RESET_PRESET_MODE) in config[CONF_PRESET_MODES_LIST] + ): + errors["fan_preset_mode_settings"] = ( + "fan_preset_mode_reset_in_preset_modes_list" + ) + + return errors + + @callback def validate_sensor_platform_config( config: dict[str, Any], @@ -454,20 +641,41 @@ def validate_sensor_platform_config( ): errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" + if ( + (state_class := config.get(CONF_STATE_CLASS)) is not None + and state_class in STATE_CLASS_UNITS + and config.get(CONF_UNIT_OF_MEASUREMENT) not in STATE_CLASS_UNITS[state_class] + ): + errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom_for_state_class" + return errors +@callback +def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: + """Run validator, then return the unmodified input.""" + + def _validate(value: Any) -> Any: + validator(value) + return value + + return _validate + + @dataclass(frozen=True, kw_only=True) class PlatformField: """Stores a platform config field schema, required flag and validator.""" selector: Selector[Any] | Callable[..., Selector[Any]] required: bool - validator: Callable[..., Any] + validator: Callable[..., Any] | None = None error: str | None = None - default: str | int | bool | None | vol.Undefined = vol.UNDEFINED + default: ( + str | int | bool | None | Callable[[dict[str, Any]], Any] | vol.Undefined + ) = vol.UNDEFINED is_schema_default: bool = False exclude_from_reconfig: bool = False + exclude_from_config: bool = False conditions: tuple[dict[str, Any], ...] | None = None custom_filtering: bool = False section: str | None = None @@ -476,11 +684,19 @@ class PlatformField: @callback def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: """Return a context based unit of measurement selector.""" + + if (state_class := user_data.get(CONF_STATE_CLASS)) in STATE_CLASS_UNITS: + return SelectSelector( + SelectSelectorConfig( + options=[str(uom) for uom in STATE_CLASS_UNITS[state_class]], + sort=True, + custom_value=True, + ) + ) + if ( - user_data is None - or (device_class := user_data.get(CONF_DEVICE_CLASS)) is None - or device_class not in DEVICE_CLASS_UNITS - ): + device_class := user_data.get(CONF_DEVICE_CLASS) + ) is None or device_class not in DEVICE_CLASS_UNITS: return TEXT_SELECTOR return SelectSelector( SelectSelectorConfig( @@ -502,17 +718,15 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]: return errors -COMMON_ENTITY_FIELDS = { +COMMON_ENTITY_FIELDS: dict[str, PlatformField] = { CONF_PLATFORM: PlatformField( 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, ), @@ -521,19 +735,62 @@ COMMON_ENTITY_FIELDS = { ), } -PLATFORM_ENTITY_FIELDS = { +PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { + Platform.BINARY_SENSOR.value: { + CONF_DEVICE_CLASS: PlatformField( + selector=BINARY_SENSOR_DEVICE_CLASS_SELECTOR, + required=False, + ), + }, + Platform.BUTTON.value: { + CONF_DEVICE_CLASS: PlatformField( + selector=BUTTON_DEVICE_CLASS_SELECTOR, + required=False, + ), + }, + Platform.COVER.value: { + CONF_DEVICE_CLASS: PlatformField( + selector=COVER_DEVICE_CLASS_SELECTOR, + required=False, + ), + }, + Platform.FAN.value: { + "fan_feature_speed": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_PERCENTAGE_COMMAND_TOPIC)), + ), + "fan_feature_preset_modes": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_PRESET_MODE_COMMAND_TOPIC)), + ), + "fan_feature_oscillation": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_OSCILLATION_COMMAND_TOPIC)), + ), + "fan_feature_direction": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_DIRECTION_COMMAND_TOPIC)), + ), + }, Platform.NOTIFY.value: {}, Platform.SENSOR.value: { CONF_DEVICE_CLASS: PlatformField( - selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False, validator=str + selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False ), CONF_STATE_CLASS: PlatformField( - selector=SENSOR_STATE_CLASS_SELECTOR, required=False, validator=str + selector=SENSOR_STATE_CLASS_SELECTOR, required=False ), CONF_UNIT_OF_MEASUREMENT: PlatformField( selector=unit_of_measurement_selector, required=False, - validator=str, custom_filtering=True, ), CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField( @@ -545,33 +802,485 @@ PLATFORM_ENTITY_FIELDS = { CONF_OPTIONS: PlatformField( selector=OPTIONS_SELECTOR, required=False, - validator=cv.ensure_list, conditions=({"device_class": "enum"},), ), }, Platform.SWITCH.value: { CONF_DEVICE_CLASS: PlatformField( - selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False, validator=str + selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False ), }, 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_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = { + Platform.BINARY_SENSOR.value: { + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OFF, + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ON, + ), + CONF_EXPIRE_AFTER: PlatformField( + selector=TIMEOUT_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", + ), + CONF_OFF_DELAY: PlatformField( + selector=TIMEOUT_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", + ), + }, + Platform.BUTTON.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_PAYLOAD_PRESS: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_PRESS, + ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + }, + Platform.COVER.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + CONF_PAYLOAD_CLOSE: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_CLOSE, + section="cover_payload_settings", + ), + CONF_PAYLOAD_OPEN: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OPEN, + section="cover_payload_settings", + ), + CONF_PAYLOAD_STOP: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=None, + section="cover_payload_settings", + ), + CONF_PAYLOAD_STOP_TILT: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_STOP, + section="cover_payload_settings", + ), + CONF_STATE_CLOSED: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=STATE_CLOSED, + section="cover_payload_settings", + ), + CONF_STATE_CLOSING: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=STATE_CLOSING, + section="cover_payload_settings", + ), + CONF_STATE_OPEN: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=STATE_OPEN, + section="cover_payload_settings", + ), + CONF_STATE_OPENING: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=STATE_OPENING, + section="cover_payload_settings", + ), + CONF_STATE_STOPPED: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_STATE_STOPPED, + section="cover_payload_settings", + ), + CONF_SET_POSITION_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="cover_position_settings", + ), + CONF_SET_POSITION_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="cover_position_settings", + ), + CONF_GET_POSITION_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="cover_position_settings", + ), + CONF_GET_POSITION_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="cover_position_settings", + ), + CONF_POSITION_CLOSED: PlatformField( + selector=POSITION_SELECTOR, + required=False, + validator=int, + default=DEFAULT_POSITION_CLOSED, + section="cover_position_settings", + ), + CONF_POSITION_OPEN: PlatformField( + selector=POSITION_SELECTOR, + required=False, + validator=int, + default=DEFAULT_POSITION_OPEN, + section="cover_position_settings", + ), + CONF_TILT_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="cover_tilt_settings", + ), + CONF_TILT_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="cover_tilt_settings", + ), + CONF_TILT_CLOSED_POSITION: PlatformField( + selector=POSITION_SELECTOR, + required=False, + validator=int, + default=DEFAULT_TILT_CLOSED_POSITION, + section="cover_tilt_settings", + ), + CONF_TILT_OPEN_POSITION: PlatformField( + selector=POSITION_SELECTOR, + required=False, + validator=int, + default=DEFAULT_TILT_OPEN_POSITION, + section="cover_tilt_settings", + ), + CONF_TILT_STATUS_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="cover_tilt_settings", + ), + CONF_TILT_STATUS_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="cover_tilt_settings", + ), + CONF_TILT_MIN: PlatformField( + selector=POSITION_SELECTOR, + required=False, + validator=int, + default=DEFAULT_TILT_MIN, + section="cover_tilt_settings", + ), + CONF_TILT_MAX: PlatformField( + selector=POSITION_SELECTOR, + required=False, + validator=int, + default=DEFAULT_TILT_MAX, + section="cover_tilt_settings", + ), + CONF_TILT_STATE_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + section="cover_tilt_settings", + ), + }, + Platform.FAN.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OFF, + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ON, + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + CONF_PERCENTAGE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="fan_speed_settings", + conditions=({"fan_feature_speed": True},), + ), + CONF_PERCENTAGE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="fan_speed_settings", + conditions=({"fan_feature_speed": True},), + ), + CONF_PERCENTAGE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="fan_speed_settings", + conditions=({"fan_feature_speed": True},), + ), + CONF_PERCENTAGE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="fan_speed_settings", + conditions=({"fan_feature_speed": True},), + ), + CONF_SPEED_RANGE_MIN: PlatformField( + selector=FAN_SPEED_RANGE_MIN_SELECTOR, + required=False, + validator=int, + default=DEFAULT_SPEED_RANGE_MIN, + section="fan_speed_settings", + conditions=({"fan_feature_speed": True},), + ), + CONF_SPEED_RANGE_MAX: PlatformField( + selector=FAN_SPEED_RANGE_MAX_SELECTOR, + required=False, + validator=int, + default=DEFAULT_SPEED_RANGE_MAX, + section="fan_speed_settings", + conditions=({"fan_feature_speed": True},), + ), + CONF_PAYLOAD_RESET_PERCENTAGE: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_RESET, + section="fan_speed_settings", + conditions=({"fan_feature_speed": True},), + ), + CONF_PRESET_MODES_LIST: PlatformField( + selector=PRESET_MODES_SELECTOR, + required=True, + section="fan_preset_mode_settings", + conditions=({"fan_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="fan_preset_mode_settings", + conditions=({"fan_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="fan_preset_mode_settings", + conditions=({"fan_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="fan_preset_mode_settings", + conditions=({"fan_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="fan_preset_mode_settings", + conditions=({"fan_feature_preset_modes": True},), + ), + CONF_PAYLOAD_RESET_PRESET_MODE: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_RESET, + section="fan_preset_mode_settings", + conditions=({"fan_feature_preset_modes": True},), + ), + CONF_OSCILLATION_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="fan_oscillation_settings", + conditions=({"fan_feature_oscillation": True},), + ), + CONF_OSCILLATION_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="fan_oscillation_settings", + conditions=({"fan_feature_oscillation": True},), + ), + CONF_OSCILLATION_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="fan_oscillation_settings", + conditions=({"fan_feature_oscillation": True},), + ), + CONF_OSCILLATION_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="fan_oscillation_settings", + conditions=({"fan_feature_oscillation": True},), + ), + CONF_PAYLOAD_OSCILLATION_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OSCILLATE_OFF, + section="fan_oscillation_settings", + conditions=({"fan_feature_oscillation": True},), + ), + CONF_PAYLOAD_OSCILLATION_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OSCILLATE_ON, + section="fan_oscillation_settings", + conditions=({"fan_feature_oscillation": True},), + ), + CONF_DIRECTION_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="fan_direction_settings", + conditions=({"fan_feature_direction": True},), + ), + CONF_DIRECTION_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="fan_direction_settings", + conditions=({"fan_feature_direction": True},), + ), + CONF_DIRECTION_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="fan_direction_settings", + conditions=({"fan_feature_direction": True},), + ), + CONF_DIRECTION_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="fan_direction_settings", + conditions=({"fan_feature_direction": True},), + ), + }, Platform.NOTIFY.value: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, @@ -582,12 +1291,10 @@ PLATFORM_MQTT_FIELDS = { CONF_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", ), - CONF_RETAIN: PlatformField( - selector=BOOLEAN_SELECTOR, required=False, validator=bool - ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, Platform.SENSOR.value: { CONF_STATE_TOPIC: PlatformField( @@ -599,18 +1306,18 @@ PLATFORM_MQTT_FIELDS = { CONF_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", ), CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_STATE_CLASS: "total"},), ), CONF_EXPIRE_AFTER: PlatformField( - selector=EXPIRE_AFTER_SELECTOR, + selector=TIMEOUT_SELECTOR, required=False, validator=cv.positive_int, section="advanced_settings", @@ -626,7 +1333,7 @@ PLATFORM_MQTT_FIELDS = { CONF_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", ), CONF_STATE_TOPIC: PlatformField( @@ -638,15 +1345,29 @@ PLATFORM_MQTT_FIELDS = { CONF_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", ), - CONF_RETAIN: PlatformField( - selector=BOOLEAN_SELECTOR, required=False, validator=bool + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OFF, ), - CONF_OPTIMISTIC: PlatformField( - selector=BOOLEAN_SELECTOR, required=False, validator=bool + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ON, ), + CONF_STATE_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + ), + CONF_STATE_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, Platform.LIGHT.value: { CONF_COMMAND_TOPIC: PlatformField( @@ -658,21 +1379,20 @@ PLATFORM_MQTT_FIELDS = { CONF_COMMAND_ON_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=True, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "template"},), ), CONF_COMMAND_OFF_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=True, - validator=cv.template, + validator=validate(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"},), ), @@ -685,14 +1405,14 @@ PLATFORM_MQTT_FIELDS = { CONF_STATE_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), ), CONF_STATE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "template"},), ), @@ -703,19 +1423,15 @@ PLATFORM_MQTT_FIELDS = { error="invalid_supported_color_modes", conditions=({CONF_SCHEMA: "json"},), ), - CONF_OPTIMISTIC: PlatformField( - selector=BOOLEAN_SELECTOR, required=False, validator=bool - ), + CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False), 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", ), @@ -730,7 +1446,7 @@ PLATFORM_MQTT_FIELDS = { CONF_BRIGHTNESS_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_brightness_settings", @@ -746,21 +1462,19 @@ PLATFORM_MQTT_FIELDS = { 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, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_brightness_settings", @@ -787,7 +1501,7 @@ PLATFORM_MQTT_FIELDS = { CONF_COLOR_MODE_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_color_mode_settings", @@ -803,7 +1517,7 @@ PLATFORM_MQTT_FIELDS = { CONF_COLOR_TEMP_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_color_temp_settings", @@ -819,7 +1533,7 @@ PLATFORM_MQTT_FIELDS = { CONF_COLOR_TEMP_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_color_temp_settings", @@ -827,35 +1541,35 @@ PLATFORM_MQTT_FIELDS = { CONF_BRIGHTNESS_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "template"},), ), CONF_RED_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "template"},), ), CONF_GREEN_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "template"},), ), CONF_BLUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "template"},), ), CONF_COLOR_TEMP_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "template"},), ), @@ -870,7 +1584,7 @@ PLATFORM_MQTT_FIELDS = { CONF_HS_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_hs_settings", @@ -886,7 +1600,7 @@ PLATFORM_MQTT_FIELDS = { CONF_HS_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_hs_settings", @@ -902,7 +1616,7 @@ PLATFORM_MQTT_FIELDS = { CONF_RGB_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_rgb_settings", @@ -918,7 +1632,7 @@ PLATFORM_MQTT_FIELDS = { CONF_RGB_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_rgb_settings", @@ -934,7 +1648,7 @@ PLATFORM_MQTT_FIELDS = { CONF_RGBW_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_rgbw_settings", @@ -950,7 +1664,7 @@ PLATFORM_MQTT_FIELDS = { CONF_RGBW_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_rgbw_settings", @@ -966,7 +1680,7 @@ PLATFORM_MQTT_FIELDS = { CONF_RGBWW_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_rgbww_settings", @@ -982,7 +1696,7 @@ PLATFORM_MQTT_FIELDS = { CONF_RGBWW_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_rgbww_settings", @@ -998,7 +1712,7 @@ PLATFORM_MQTT_FIELDS = { CONF_XY_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_xy_settings", @@ -1014,7 +1728,7 @@ PLATFORM_MQTT_FIELDS = { CONF_XY_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_xy_settings", @@ -1041,7 +1755,6 @@ PLATFORM_MQTT_FIELDS = { CONF_EFFECT: PlatformField( selector=BOOLEAN_SELECTOR, required=False, - validator=bool, conditions=({CONF_SCHEMA: "json"},), section="light_effect_settings", ), @@ -1056,7 +1769,7 @@ PLATFORM_MQTT_FIELDS = { CONF_EFFECT_COMMAND_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_effect_settings", @@ -1072,7 +1785,7 @@ PLATFORM_MQTT_FIELDS = { CONF_EFFECT_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "template"},), section="light_effect_settings", @@ -1080,7 +1793,7 @@ PLATFORM_MQTT_FIELDS = { CONF_EFFECT_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, - validator=cv.template, + validator=validate(cv.template), error="invalid_template", conditions=({CONF_SCHEMA: "basic"},), section="light_effect_settings", @@ -1088,7 +1801,6 @@ PLATFORM_MQTT_FIELDS = { CONF_EFFECT_LIST: PlatformField( selector=OPTIONS_SELECTOR, required=False, - validator=cv.ensure_list, section="light_effect_settings", ), CONF_FLASH: PlatformField( @@ -1143,6 +1855,10 @@ ENTITY_CONFIG_VALIDATOR: dict[ str, Callable[[dict[str, Any]], dict[str, str]] | None, ] = { + Platform.BINARY_SENSOR.value: None, + Platform.BUTTON.value: None, + Platform.COVER.value: validate_cover_platform_config, + Platform.FAN.value: validate_fan_platform_config, Platform.LIGHT.value: validate_light_platform_config, Platform.NOTIFY.value: None, Platform.SENSOR.value: validate_sensor_platform_config, @@ -1150,15 +1866,11 @@ ENTITY_CONFIG_VALIDATOR: dict[ } MQTT_DEVICE_PLATFORM_FIELDS = { - 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_NAME: PlatformField(selector=TEXT_SELECTOR, required=True), + ATTR_SW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), + ATTR_HW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), + ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False), + ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False), ATTR_CONFIGURATION_URL: PlatformField( selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" ), @@ -1212,10 +1924,10 @@ def validate_field( error: str, ) -> None: """Validate a single field.""" - if user_input is None or field not in user_input: + if user_input is None or field not in user_input or validator is None: return try: - validator(user_input[field]) + user_input[field] = validator(user_input[field]) except (ValueError, vol.Error, vol.Invalid): errors[field] = error @@ -1273,7 +1985,9 @@ def validate_user_input( for field, value in merged_user_input.items(): validator = data_schema_fields[field].validator try: - validator(value) + merged_user_input[field] = ( + validator(value) if validator is not None else value + ) except (ValueError, vol.Error, vol.Invalid): data_schema_field = data_schema_fields[field] errors[data_schema_field.section or field] = ( @@ -1302,6 +2016,14 @@ def data_schema_from_fields( device_data: MqttDeviceData | None = None, ) -> vol.Schema: """Generate custom data schema from platform fields or device data.""" + + def get_default(field_details: PlatformField) -> Any: + if callable(field_details.default): + if TYPE_CHECKING: + assert component_data is not None + return field_details.default(component_data) + return field_details.default + if device_data is not None: component_data_with_user_input: dict[str, Any] | None = dict(device_data) if TYPE_CHECKING: @@ -1328,7 +2050,7 @@ def data_schema_from_fields( if field_details.required else vol.Optional( field_name, - default=field_details.default + default=get_default(field_details) if field_details.default is not None else vol.UNDEFINED, ): field_details.selector(component_data_with_user_input) # type: ignore[operator] @@ -2216,13 +2938,21 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): """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( + platform_fields: dict[str, PlatformField] = ( COMMON_ENTITY_FIELDS | PLATFORM_ENTITY_FIELDS[platform] - | PLATFORM_MQTT_FIELDS[platform], + | PLATFORM_MQTT_FIELDS[platform] + ) + subentry_default_data = subentry_schema_default_data_from_fields( + platform_fields, component_data, ) component_data.update(subentry_default_data) + for key, platform_field in platform_fields.items(): + if not platform_field.exclude_from_config: + continue + if key in component_data: + component_data.pop(key) @callback def _async_create_subentry( diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 18107c5c939..c60aa674b1b 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -78,6 +78,10 @@ CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" +CONF_DIRECTION_COMMAND_TEMPLATE = "direction_command_template" +CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" +CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" +CONF_DIRECTION_VALUE_TEMPLATE = "direction_value_template" CONF_ENABLED_BY_DEFAULT = "enabled_by_default" CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template" CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic" @@ -90,6 +94,8 @@ CONF_EXPIRE_AFTER = "expire_after" CONF_FLASH = "flash" CONF_FLASH_TIME_LONG = "flash_time_long" CONF_FLASH_TIME_SHORT = "flash_time_short" +CONF_GET_POSITION_TEMPLATE = "position_template" +CONF_GET_POSITION_TOPIC = "position_topic" CONF_GREEN_TEMPLATE = "green_template" CONF_HS_COMMAND_TEMPLATE = "hs_command_template" CONF_HS_COMMAND_TOPIC = "hs_command_topic" @@ -105,15 +111,35 @@ CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_OFF_DELAY = "off_delay" CONF_ON_COMMAND_TYPE = "on_command_type" +CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic" +CONF_OSCILLATION_COMMAND_TEMPLATE = "oscillation_command_template" +CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic" +CONF_OSCILLATION_VALUE_TEMPLATE = "oscillation_value_template" CONF_PAYLOAD_CLOSE = "payload_close" CONF_PAYLOAD_OPEN = "payload_open" +CONF_PAYLOAD_OSCILLATION_OFF = "payload_oscillation_off" +CONF_PAYLOAD_OSCILLATION_ON = "payload_oscillation_on" +CONF_PAYLOAD_PRESS = "payload_press" +CONF_PAYLOAD_RESET_PERCENTAGE = "payload_reset_percentage" +CONF_PAYLOAD_RESET_PRESET_MODE = "payload_reset_preset_mode" CONF_PAYLOAD_STOP = "payload_stop" +CONF_PAYLOAD_STOP_TILT = "payload_stop_tilt" +CONF_PERCENTAGE_COMMAND_TEMPLATE = "percentage_command_template" +CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic" +CONF_PERCENTAGE_STATE_TOPIC = "percentage_state_topic" +CONF_PERCENTAGE_VALUE_TEMPLATE = "percentage_value_template" CONF_POSITION_CLOSED = "position_closed" CONF_POSITION_OPEN = "position_open" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" +CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" +CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" +CONF_PRESET_MODES_LIST = "preset_modes" +CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" +CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" CONF_RED_TEMPLATE = "red_template" CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template" CONF_RGB_COMMAND_TOPIC = "rgb_command_topic" @@ -127,10 +153,17 @@ CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template" CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic" CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic" CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template" +CONF_SET_POSITION_TEMPLATE = "set_position_template" +CONF_SET_POSITION_TOPIC = "set_position_topic" +CONF_SPEED_RANGE_MAX = "speed_range_max" +CONF_SPEED_RANGE_MIN = "speed_range_min" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" +CONF_STATE_OFF = "state_off" +CONF_STATE_ON = "state_on" CONF_STATE_OPEN = "state_open" CONF_STATE_OPENING = "state_opening" +CONF_STATE_STOPPED = "state_stopped" CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" @@ -140,6 +173,15 @@ CONF_TEMP_STATE_TOPIC = "temperature_state_topic" CONF_TEMP_INITIAL = "initial" CONF_TEMP_MAX = "max_temp" CONF_TEMP_MIN = "min_temp" +CONF_TILT_COMMAND_TEMPLATE = "tilt_command_template" +CONF_TILT_COMMAND_TOPIC = "tilt_command_topic" +CONF_TILT_STATUS_TOPIC = "tilt_status_topic" +CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" +CONF_TILT_CLOSED_POSITION = "tilt_closed_value" +CONF_TILT_MAX = "tilt_max" +CONF_TILT_MIN = "tilt_min" +CONF_TILT_OPEN_POSITION = "tilt_opened_value" +CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic" CONF_TRANSITION = "transition" CONF_XY_COMMAND_TEMPLATE = "xy_command_template" CONF_XY_COMMAND_TOPIC = "xy_command_topic" @@ -187,15 +229,31 @@ DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" DEFAULT_PAYLOAD_OFF = "OFF" DEFAULT_PAYLOAD_ON = "ON" DEFAULT_PAYLOAD_OPEN = "OPEN" +DEFAULT_PAYLOAD_OSCILLATE_OFF = "oscillate_off" +DEFAULT_PAYLOAD_OSCILLATE_ON = "oscillate_on" +DEFAULT_PAYLOAD_PRESS = "PRESS" +DEFAULT_PAYLOAD_STOP = "STOP" +DEFAULT_PAYLOAD_RESET = "None" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False +DEFAULT_TILT_CLOSED_POSITION = 0 +DEFAULT_TILT_MAX = 100 +DEFAULT_TILT_MIN = 0 +DEFAULT_TILT_OPEN_POSITION = 100 +DEFAULT_TILT_OPTIMISTIC = False DEFAULT_WS_HEADERS: dict[str, str] = {} DEFAULT_WS_PATH = "/" DEFAULT_POSITION_CLOSED = 0 DEFAULT_POSITION_OPEN = 100 DEFAULT_RETAIN = False +DEFAULT_SPEED_RANGE_MAX = 100 +DEFAULT_SPEED_RANGE_MIN = 1 +DEFAULT_STATE_STOPPED = "stopped" DEFAULT_WHITE_SCALE = 255 +COVER_PAYLOAD = "cover" +TILT_PAYLOAD = "tilt" + VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] PROTOCOL_31 = "3.1" diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 428c4d0e205..201f28099c8 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -43,23 +43,45 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TOPIC, + CONF_GET_POSITION_TEMPLATE, + CONF_GET_POSITION_TOPIC, CONF_PAYLOAD_CLOSE, CONF_PAYLOAD_OPEN, CONF_PAYLOAD_STOP, + CONF_PAYLOAD_STOP_TILT, CONF_POSITION_CLOSED, CONF_POSITION_OPEN, CONF_RETAIN, + CONF_SET_POSITION_TEMPLATE, + CONF_SET_POSITION_TOPIC, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, CONF_STATE_OPENING, + CONF_STATE_STOPPED, CONF_STATE_TOPIC, + CONF_TILT_CLOSED_POSITION, + CONF_TILT_COMMAND_TEMPLATE, + CONF_TILT_COMMAND_TOPIC, + CONF_TILT_MAX, + CONF_TILT_MIN, + CONF_TILT_OPEN_POSITION, + CONF_TILT_STATE_OPTIMISTIC, + CONF_TILT_STATUS_TEMPLATE, + CONF_TILT_STATUS_TOPIC, DEFAULT_OPTIMISTIC, DEFAULT_PAYLOAD_CLOSE, DEFAULT_PAYLOAD_OPEN, + DEFAULT_PAYLOAD_STOP, DEFAULT_POSITION_CLOSED, DEFAULT_POSITION_OPEN, DEFAULT_RETAIN, + DEFAULT_STATE_STOPPED, + DEFAULT_TILT_CLOSED_POSITION, + DEFAULT_TILT_MAX, + DEFAULT_TILT_MIN, + DEFAULT_TILT_OPEN_POSITION, + DEFAULT_TILT_OPTIMISTIC, PAYLOAD_NONE, ) from .entity import MqttEntity, async_setup_entity_entry_helper @@ -71,37 +93,8 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -CONF_GET_POSITION_TOPIC = "position_topic" -CONF_GET_POSITION_TEMPLATE = "position_template" -CONF_SET_POSITION_TOPIC = "set_position_topic" -CONF_SET_POSITION_TEMPLATE = "set_position_template" -CONF_TILT_COMMAND_TOPIC = "tilt_command_topic" -CONF_TILT_COMMAND_TEMPLATE = "tilt_command_template" -CONF_TILT_STATUS_TOPIC = "tilt_status_topic" -CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" - -CONF_STATE_STOPPED = "state_stopped" -CONF_PAYLOAD_STOP_TILT = "payload_stop_tilt" -CONF_TILT_CLOSED_POSITION = "tilt_closed_value" -CONF_TILT_MAX = "tilt_max" -CONF_TILT_MIN = "tilt_min" -CONF_TILT_OPEN_POSITION = "tilt_opened_value" -CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic" - -TILT_PAYLOAD = "tilt" -COVER_PAYLOAD = "cover" - DEFAULT_NAME = "MQTT Cover" -DEFAULT_STATE_STOPPED = "stopped" -DEFAULT_PAYLOAD_STOP = "STOP" - -DEFAULT_TILT_CLOSED_POSITION = 0 -DEFAULT_TILT_MAX = 100 -DEFAULT_TILT_MIN = 0 -DEFAULT_TILT_OPEN_POSITION = 100 -DEFAULT_TILT_OPTIMISTIC = False - TILT_FEATURES = ( CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 3fac4d4ffe0..39ea543c809 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -43,8 +43,38 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_DIRECTION_COMMAND_TEMPLATE, + CONF_DIRECTION_COMMAND_TOPIC, + CONF_DIRECTION_STATE_TOPIC, + CONF_DIRECTION_VALUE_TEMPLATE, + CONF_OSCILLATION_COMMAND_TEMPLATE, + CONF_OSCILLATION_COMMAND_TOPIC, + CONF_OSCILLATION_STATE_TOPIC, + CONF_OSCILLATION_VALUE_TEMPLATE, + CONF_PAYLOAD_OSCILLATION_OFF, + CONF_PAYLOAD_OSCILLATION_ON, + CONF_PAYLOAD_RESET_PERCENTAGE, + CONF_PAYLOAD_RESET_PRESET_MODE, + CONF_PERCENTAGE_COMMAND_TEMPLATE, + CONF_PERCENTAGE_COMMAND_TOPIC, + CONF_PERCENTAGE_STATE_TOPIC, + CONF_PERCENTAGE_VALUE_TEMPLATE, + CONF_PRESET_MODE_COMMAND_TEMPLATE, + CONF_PRESET_MODE_COMMAND_TOPIC, + CONF_PRESET_MODE_STATE_TOPIC, + CONF_PRESET_MODE_VALUE_TEMPLATE, + CONF_PRESET_MODES_LIST, + CONF_SPEED_RANGE_MAX, + CONF_SPEED_RANGE_MIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, + DEFAULT_PAYLOAD_OSCILLATE_OFF, + DEFAULT_PAYLOAD_OSCILLATE_ON, + DEFAULT_PAYLOAD_RESET, + DEFAULT_SPEED_RANGE_MAX, + DEFAULT_SPEED_RANGE_MIN, PAYLOAD_NONE, ) from .entity import MqttEntity, async_setup_entity_entry_helper @@ -59,39 +89,7 @@ from .util import valid_publish_topic, valid_subscribe_topic PARALLEL_UPDATES = 0 -CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" -CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" -CONF_DIRECTION_VALUE_TEMPLATE = "direction_value_template" -CONF_DIRECTION_COMMAND_TEMPLATE = "direction_command_template" -CONF_PERCENTAGE_STATE_TOPIC = "percentage_state_topic" -CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic" -CONF_PERCENTAGE_VALUE_TEMPLATE = "percentage_value_template" -CONF_PERCENTAGE_COMMAND_TEMPLATE = "percentage_command_template" -CONF_PAYLOAD_RESET_PERCENTAGE = "payload_reset_percentage" -CONF_SPEED_RANGE_MIN = "speed_range_min" -CONF_SPEED_RANGE_MAX = "speed_range_max" -CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" -CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" -CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" -CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" -CONF_PRESET_MODES_LIST = "preset_modes" -CONF_PAYLOAD_RESET_PRESET_MODE = "payload_reset_preset_mode" -CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic" -CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic" -CONF_OSCILLATION_VALUE_TEMPLATE = "oscillation_value_template" -CONF_OSCILLATION_COMMAND_TEMPLATE = "oscillation_command_template" -CONF_PAYLOAD_OSCILLATION_ON = "payload_oscillation_on" -CONF_PAYLOAD_OSCILLATION_OFF = "payload_oscillation_off" - DEFAULT_NAME = "MQTT Fan" -DEFAULT_PAYLOAD_ON = "ON" -DEFAULT_PAYLOAD_OFF = "OFF" -DEFAULT_PAYLOAD_RESET = "None" -DEFAULT_SPEED_RANGE_MIN = 1 -DEFAULT_SPEED_RANGE_MAX = 100 - -OSCILLATE_ON_PAYLOAD = "oscillate_on" -OSCILLATE_OFF_PAYLOAD = "oscillate_off" MQTT_FAN_ATTRIBUTES_BLOCKED = frozenset( { @@ -165,10 +163,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( 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_PAYLOAD_OSCILLATION_OFF, default=OSCILLATE_OFF_PAYLOAD + CONF_PAYLOAD_OSCILLATION_OFF, default=DEFAULT_PAYLOAD_OSCILLATE_OFF ): cv.string, vol.Optional( - CONF_PAYLOAD_OSCILLATION_ON, default=OSCILLATE_ON_PAYLOAD + CONF_PAYLOAD_OSCILLATION_ON, default=DEFAULT_PAYLOAD_OSCILLATE_ON ): cv.string, vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, } diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index b27ef68368a..46d475fcee8 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, + STATE_CLASS_UNITS, STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, @@ -117,6 +118,17 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) + if ( + (state_class := config.get(CONF_STATE_CLASS)) is not None + and state_class in STATE_CLASS_UNITS + and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) + not in STATE_CLASS_UNITS[state_class] + ): + raise vol.Invalid( + f"The unit of measurement '{unit_of_measurement}' is not valid " + f"together with state class '{state_class}'" + ) + if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) ) is None: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 7339f3869a1..9bc6df1b633 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -57,7 +57,7 @@ "set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT brokers certificate.", "set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.", "transport": "The transport to be used for the connection to your MQTT broker.", - "ws_headers": "The WebSocket headers to pass through the WebSocket based connection to your MQTT broker.", + "ws_headers": "The WebSocket headers to pass through the WebSocket-based connection to your MQTT broker.", "ws_path": "The WebSocket path to be used for the connection to your MQTT broker." } }, @@ -214,6 +214,10 @@ "description": "Please configure specific details for {platform} entity \"{entity}\":", "data": { "device_class": "Device class", + "fan_feature_speed": "Speed support", + "fan_feature_preset_modes": "Preset modes support", + "fan_feature_oscillation": "Oscillation support", + "fan_feature_direction": "Direction support", "options": "Add option", "schema": "Schema", "state_class": "State class", @@ -222,6 +226,10 @@ }, "data_description": { "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)", + "fan_feature_speed": "The fan supports multiple speeds.", + "fan_feature_preset_modes": "The fan supports preset modes.", + "fan_feature_oscillation": "The fan supports oscillation.", + "fan_feature_direction": "The fan supports direction.", "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)", @@ -244,7 +252,6 @@ "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", "data": { - "on_command_type": "ON command type", "blue_template": "Blue template", "brightness_template": "Brightness template", "command_template": "Command template", @@ -255,12 +262,16 @@ "force_update": "Force update", "green_template": "Green template", "last_reset_value_template": "Last reset value template", + "on_command_type": "ON command type", "optimistic": "Optimistic", "payload_off": "Payload \"off\"", "payload_on": "Payload \"on\"", + "payload_press": "Payload \"press\"", "qos": "QoS", "red_template": "Red template", "retain": "Retain", + "state_off": "State \"off\"", + "state_on": "State \"on\"", "state_template": "State template", "state_topic": "State topic", "state_value_template": "State value template", @@ -275,19 +286,22 @@ "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.", "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.", + "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)", "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)", - "payload_off": "The payload that represents the off state.", - "payload_on": "The payload that represents the on state.", + "payload_off": "The payload that represents the \"off\" state.", + "payload_on": "The payload that represents the \"on\" state.", + "payload_press": "The payload to send when the button is triggered.", "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_off": "The incoming payload that represents the \"off\" state. Use only when the value that represents \"off\" state in the state topic is different from value that should be sent to the command topic to turn the device off.", + "state_on": "The incoming payload that represents the \"on\" state. Use only when the value that represents \"on\" state in the state topic is different from value that should be sent to the command topic to turn the device on.", "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)", + "supported_color_modes": "A list of color modes supported by the light. 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": { @@ -300,6 +314,7 @@ "flash_time_short": "Flash time short", "max_kelvin": "Max Kelvin", "min_kelvin": "Min Kelvin", + "off_delay": "OFF delay", "transition": "Transition support" }, "data_description": { @@ -309,9 +324,79 @@ "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.", + "off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensor’s state will be updated back to \"off\".", "transition": "Enable the transition feature for this light" } }, + "cover_payload_settings": { + "name": "Payload settings", + "data": { + "payload_close": "Payload \"close\"", + "payload_open": "Payload \"open\"", + "payload_stop": "Payload \"stop\"", + "payload_stop_tilt": "Payload \"stop tilt\"", + "state_closed": "State \"closed\"", + "state_closing": "State \"closing\"", + "state_open": "State \"open\"", + "state_opening": "State \"opening\"", + "state_stopped": "State \"stopped\"" + }, + "data_description": { + "payload_close": "The payload sent when a \"close\" command is issued.", + "payload_open": "The payload sent when an \"open\" command is issued.", + "payload_stop": "The payload sent when a \"stop\" command is issued. Leave empty to disable the \"stop\" feature.", + "payload_stop_tilt": "The payload sent when a \"stop tilt\" command is issued.", + "state_closed": "The payload received at the state topic that represents the \"closed\" state.", + "state_closing": "The payload received at the state topic that represents the \"closing\" state.", + "state_open": "The payload received at the state topic that represents the \"open\" state.", + "state_opening": "The payload received at the state topic that represents the \"opening\" state.", + "state_stopped": "The payload received at the state topic that represents the \"stopped\" state (for covers that do not report \"open\"/\"closed\" state)." + } + }, + "cover_position_settings": { + "name": "Position settings", + "data": { + "position_closed": "Position \"closed\" value", + "position_open": "Position \"open\" value", + "position_template": "Position value template", + "position_topic": "Position state topic", + "set_position_template": "Set position template", + "set_position_topic": "Set position topic" + }, + "data_description": { + "position_closed": "Number which represents \"closed\" position.", + "position_open": "Number which represents \"open\" position.", + "position_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the payload for the position topic. Within the template the following variables are also available: `entity_id`, `position_open`, `position_closed`, `tilt_min` and `tilt_max`. [Learn more.]({url}#position_template)", + "position_topic": "The MQTT topic subscribed to receive cover position state messages. [Learn more.]({url}#position_topic)", + "set_position_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to define the position to be sent to the set position topic. Within the template the following variables are available: `value` (the scaled target position), `entity_id`, `position` (the target position percentage), `position_open`, `position_closed`, `tilt_min` and `tilt_max`. [Learn more.]({url}#set_position_template)", + "set_position_topic": "The MQTT topic to publish position commands to. You need to use the set position topic as well if you want to use the position topic. Use template if position topic wants different values than within range \"position closed\" - \"position_open\". If template is not defined and position \"closed\" != 100 and position \"open\" != 0 then proper position value is calculated from percentage position. [Learn more.]({url}#set_position_topic)" + } + }, + "cover_tilt_settings": { + "name": "Tilt settings", + "data": { + "tilt_closed_value": "Tilt \"closed\" value", + "tilt_command_template": "Tilt command template", + "tilt_command_topic": "Tilt command topic", + "tilt_max": "Tilt max", + "tilt_min": "Tilt min", + "tilt_opened_value": "Tilt \"opened\" value", + "tilt_status_template": "Tilt value template", + "tilt_status_topic": "Tilt status topic", + "tilt_optimistic": "Tilt optimistic" + }, + "data_description": { + "tilt_closed_value": "The value that will be sent to the \"tilt command topic\" when the cover tilt is closed.", + "tilt_command_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to define the position to be sent to the tilt command topic. Within the template the following variables are available: `entity_id`, `tilt_position` (the target tilt position percentage), `position_open`, `position_closed`, `tilt_min` and `tilt_max`. [Learn more.]({url}#tilt_command_template)", + "tilt_command_topic": "The MQTT topic to publish commands to control the cover tilt. [Learn more.]({url}#tilt_command_topic)", + "tilt_max": "The maximum tilt value.", + "tilt_min": "The minimum tilt value.", + "tilt_opened_value": "The value that will be sent to the \"tilt command topic\" when the cover tilt is opened.", + "tilt_status_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the payload for the tilt status topic. Within the template the following variables are available: `entity_id`, `position_open`, `position_closed`, `tilt_min` and `tilt_max`. [Learn more.]({url}#tilt_status_template)", + "tilt_status_topic": "The MQTT topic subscribed to receive tilt status update values. [Learn more.]({url}#tilt_status_topic)", + "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimisic mode by default. [Learn more.]({url}#tilt_optimistic)" + } + }, "light_brightness_settings": { "name": "Brightness settings", "data": { @@ -331,6 +416,80 @@ "brightness_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the brightness value." } }, + "fan_direction_settings": { + "name": "Direction settings", + "data": { + "direction_command_topic": "Direction command topic", + "direction_command_template": "Direction command template", + "direction_state_topic": "Direction state topic", + "direction_value_template": "Direction value template" + }, + "data_description": { + "direction_command_topic": "The MQTT topic to publish commands to change the fan direction payload, either `forward` or `reverse`. Use the direction command template to customize the payload. [Learn more.]({url}#direction_command_topic)", + "direction_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 direction command topic. The template variable `value` will be either `forward` or `reverse`.", + "direction_state_topic": "The MQTT topic subscribed to receive fan direction state. Accepted state payloads are `forward` or `reverse`. [Learn more.]({url}#direction_state_topic)", + "direction_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract fan direction state value. The template should return either `forward` or `reverse`. When the template returns an empty string, the direction will be ignored." + } + }, + "fan_oscillation_settings": { + "name": "Oscillation settings", + "data": { + "oscillation_command_topic": "Oscillation command topic", + "oscillation_command_template": "Oscillation command template", + "oscillation_state_topic": "Oscillation state topic", + "oscillation_value_template": "Oscillation value template", + "payload_oscillation_off": "Payload \"oscillation off\"", + "payload_oscillation_on": "Payload \"oscillation on\"" + }, + "data_description": { + "oscillation_command_topic": "The MQTT topic to publish commands to change the fan oscillation state. [Learn more.]({url}#oscillation_command_topic)", + "oscillation_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 oscillation command topic.", + "oscillation_state_topic": "The MQTT topic subscribed to receive fan oscillation state. [Learn more.]({url}#oscillation_state_topic)", + "oscillation_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract fan oscillation state value.", + "payload_oscillation_off": "The payload that represents the oscillation \"off\" state.", + "payload_oscillation_on": "The payload that represents the oscillation \"on\" state." + } + }, + "fan_preset_mode_settings": { + "name": "Preset mode settings", + "data": { + "payload_reset_preset_mode": "Payload \"reset preset mode\"", + "preset_modes": "Preset modes", + "preset_mode_command_topic": "Preset mode command topic", + "preset_mode_command_template": "Preset mode command template", + "preset_mode_state_topic": "Preset mode state topic", + "preset_mode_value_template": "Preset mode value template" + }, + "data_description": { + "payload_reset_preset_mode": "A special payload that resets the fan preset mode state attribute to unknown when received at the preset mode state topic.", + "preset_modes": "List of preset modes this fan is capable of running at. Common examples include auto, smart, whoosh, eco and breeze.", + "preset_mode_command_topic": "The MQTT topic to publish commands to change the fan preset mode. [Learn more.]({url}#preset_mode_command_topic)", + "preset_mode_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 preset mode command topic.", + "preset_mode_state_topic": "The MQTT topic subscribed to receive fan preset mode. [Learn more.]({url}#preset_mode_state_topic)", + "preset_mode_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract fan preset mode value." + } + }, + "fan_speed_settings": { + "name": "Speed settings", + "data": { + "payload_reset_percentage": "Payload \"reset percentage\"", + "percentage_command_topic": "Percentage command topic", + "percentage_command_template": "Percentage command template", + "percentage_state_topic": "Percentage state topic", + "percentage_value_template": "Percentage value template", + "speed_range_min": "Speed range min", + "speed_range_max": "Speed range max" + }, + "data_description": { + "payload_reset_percentage": "A special payload that resets the fan speed percentage state attribute to unknown when received at the percentage state topic.", + "percentage_command_topic": "The MQTT topic to publish commands to change the fan speed state based on a percentage. [Learn more.]({url}#percentage_command_topic)", + "percentage_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 percentage command topic.", + "percentage_state_topic": "The MQTT topic subscribed to receive fan speed based on percentage. [Learn more.]({url}#percentage_state_topic)", + "percentage_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the speed percentage value.", + "speed_range_min": "The minimum of numeric output range (off not included, so speed_range_min - 1 represents 0 %). The percentage step is 100 / the number of speeds within the \"speed range\".", + "speed_range_max": "The maximum of numeric output range (representing 100 %). The percentage step is 100 / number of speeds within the \"speed range\"." + } + }, "light_color_mode_settings": { "name": "Color mode settings", "data": { @@ -472,11 +631,20 @@ "default": "MQTT device with {platform} entity \"{entity}\" was set up successfully.\n\nNote that you can reconfigure the MQTT device at any time, e.g. to add more entities." }, "error": { + "cover_get_and_set_position_must_be_set_together": "The get position and set position topic options must be set together", + "cover_get_position_template_must_be_used_with_get_position_topic": "The position value template must be used together with the position state topic", + "cover_set_position_template_must_be_used_with_set_position_topic": "The set position template must be used with the set position topic", + "cover_tilt_command_template_must_be_used_with_tilt_command_topic": "The tilt command template must be used with the tilt command topic", + "cover_tilt_status_template_must_be_used_with_tilt_status_topic": "The tilt value template must be used with the tilt status topic", + "cover_value_template_must_be_used_with_state_topic": "The value template must be used with the state topic option", + "fan_speed_range_max_must_be_greater_than_speed_range_min": "Speed range max must be greater than speed range min", + "fan_preset_mode_reset_in_preset_modes_list": "Payload \"reset preset mode\" is not a valid as a preset mode", "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_uom_for_state_class": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected state class, please either remove the state class, select a state 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", @@ -600,6 +768,59 @@ } }, "selector": { + "device_class_binary_sensor": { + "options": { + "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", + "battery_charging": "[%key:component::binary_sensor::entity_component::battery_charging::name%]", + "carbon_monoxide": "[%key:component::binary_sensor::entity_component::carbon_monoxide::name%]", + "cold": "[%key:component::binary_sensor::entity_component::cold::name%]", + "connectivity": "[%key:component::binary_sensor::entity_component::connectivity::name%]", + "door": "[%key:component::binary_sensor::entity_component::door::name%]", + "garage_door": "[%key:component::binary_sensor::entity_component::garage_door::name%]", + "gas": "[%key:component::binary_sensor::entity_component::gas::name%]", + "heat": "[%key:component::binary_sensor::entity_component::heat::name%]", + "light": "[%key:component::binary_sensor::entity_component::light::name%]", + "lock": "[%key:component::binary_sensor::entity_component::lock::name%]", + "moisture": "[%key:component::binary_sensor::entity_component::moisture::name%]", + "motion": "[%key:component::binary_sensor::entity_component::motion::name%]", + "moving": "[%key:component::binary_sensor::entity_component::moving::name%]", + "occupancy": "[%key:component::binary_sensor::entity_component::occupancy::name%]", + "opening": "[%key:component::binary_sensor::entity_component::opening::name%]", + "plug": "[%key:component::binary_sensor::entity_component::plug::name%]", + "power": "[%key:component::binary_sensor::entity_component::power::name%]", + "presence": "[%key:component::binary_sensor::entity_component::presence::name%]", + "problem": "[%key:component::binary_sensor::entity_component::problem::name%]", + "running": "[%key:component::binary_sensor::entity_component::running::name%]", + "safety": "[%key:component::binary_sensor::entity_component::safety::name%]", + "smoke": "[%key:component::binary_sensor::entity_component::smoke::name%]", + "sound": "[%key:component::binary_sensor::entity_component::sound::name%]", + "tamper": "[%key:component::binary_sensor::entity_component::tamper::name%]", + "update": "[%key:component::binary_sensor::entity_component::update::name%]", + "vibration": "[%key:component::binary_sensor::entity_component::vibration::name%]", + "window": "[%key:component::binary_sensor::entity_component::window::name%]" + } + }, + "device_class_button": { + "options": { + "identify": "[%key:component::button::entity_component::identify::name%]", + "restart": "[%key:common::action::restart%]", + "update": "[%key:component::button::entity_component::update::name%]" + } + }, + "device_class_cover": { + "options": { + "awning": "[%key:component::cover::entity_component::awning::name%]", + "blind": "[%key:component::cover::entity_component::blind::name%]", + "curtain": "[%key:component::cover::entity_component::curtain::name%]", + "damper": "[%key:component::cover::entity_component::damper::name%]", + "door": "[%key:component::cover::entity_component::door::name%]", + "garage": "[%key:component::cover::entity_component::garage::name%]", + "gate": "[%key:component::cover::entity_component::gate::name%]", + "shade": "[%key:component::cover::entity_component::shade::name%]", + "shutter": "[%key:component::cover::entity_component::shutter::name%]", + "window": "[%key:component::cover::entity_component::window::name%]" + } + }, "device_class_sensor": { "options": { "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", @@ -682,6 +903,10 @@ }, "platform": { "options": { + "binary_sensor": "[%key:component::binary_sensor::title%]", + "button": "[%key:component::button::title%]", + "cover": "[%key:component::cover::title%]", + "fan": "[%key:component::fan::title%]", "light": "[%key:component::light::title%]", "notify": "[%key:component::notify::title%]", "sensor": "[%key:component::sensor::title%]", @@ -698,6 +923,7 @@ "state_class": { "options": { "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]", "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%]" } diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index f6996fc77ce..fa33751f37d 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -31,7 +31,11 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_STATE_OFF, + CONF_STATE_ON, CONF_STATE_TOPIC, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, PAYLOAD_NONE, ) from .entity import MqttEntity, async_setup_entity_entry_helper @@ -46,10 +50,6 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA PARALLEL_UPDATES = 0 DEFAULT_NAME = "MQTT Switch" -DEFAULT_PAYLOAD_ON = "ON" -DEFAULT_PAYLOAD_OFF = "OFF" -CONF_STATE_ON = "state_on" -CONF_STATE_OFF = "state_off" PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( { diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 145f0a2562c..5591e5d801d 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -105,10 +105,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): @property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend.""" - if self._attr_entity_picture is not None: - return self._attr_entity_picture - - return super().entity_picture + return self._attr_entity_picture @staticmethod def config_schema() -> VolSchemaType: diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 5dc8ab2ec00..a11e334824a 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -42,7 +42,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ATTR_NAME, STATE_OFF from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -227,6 +227,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 + self._source_list_mapping: dict[str, str] = {} async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -292,6 +293,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState(player.state.value) else: self._attr_state = MediaPlayerState(STATE_OFF) + # active source and source list (translate to HA source names) + source_mappings: dict[str, str] = {} + active_source_name: str | None = None + for source in player.source_list: + if source.id == player.active_source: + active_source_name = source.name + if source.passive: + # ignore passive sources because HA does not differentiate between + # active and passive sources + continue + source_mappings[source.name] = source.id + self._attr_source_list = list(source_mappings.keys()) + self._source_list_mapping = source_mappings + self._attr_source = active_source_name group_members: list[str] = [] if player.group_childs: @@ -459,6 +474,16 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Remove this player from any group.""" await self.mass.players.player_command_ungroup(self.player_id) + @catch_musicassistant_error + async def async_select_source(self, source: str) -> None: + """Select input source.""" + source_id = self._source_list_mapping.get(source) + if source_id is None: + raise ServiceValidationError( + f"Source '{source}' not found for player {self.name}" + ) + await self.mass.players.player_command_select_source(self.player_id, source_id) + @catch_musicassistant_error async def _async_handle_play_media( self, @@ -735,4 +760,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): if self.player.power_control != PLAYER_CONTROL_NONE: supported_features |= MediaPlayerEntityFeature.TURN_ON supported_features |= MediaPlayerEntityFeature.TURN_OFF + if PlayerFeature.SELECT_SOURCE in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.SELECT_SOURCE self._attr_supported_features = supported_features diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 3b14cdd4630..0a3f7d2ebb6 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -293,8 +293,8 @@ class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): @property def native_value(self) -> StateType: """Sensor state value.""" - device_point = self.coordinator.data.points[self.device_id][self.point_id] - if device_point.value == MARKER_FOR_UNKNOWN_VALUE: + device_point = self.coordinator.data.points[self.device_id].get(self.point_id) + if device_point is None or device_point.value == MARKER_FOR_UNKNOWN_VALUE: return None return device_point.value # type: ignore[no-any-return] diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 6d42110d53e..214b63d6668 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -125,8 +125,10 @@ class NanoleafLight(NanoleafEntity, LightEntity): await self._nanoleaf.turn_on() if brightness: await self._nanoleaf.set_brightness(int(brightness / 2.55)) + await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" transition: float | None = kwargs.get(ATTR_TRANSITION) await self._nanoleaf.turn_off(None if transition is None else int(transition)) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 54f543aa845..5146d04af0b 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -6,7 +6,7 @@ "step": { "create_cloud_project": { "title": "Nest: Create and configure Cloud Project", - "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, select **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then select **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and select **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and select **Enable**.\n\nProceed when your cloud project is set up." + "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one-time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, select **Create Project** then **New Project**.\n1. Give your Cloud Project a name and then select **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and select **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and select **Enable**.\n\nProceed when your Cloud Project is set up." }, "cloud_project": { "title": "Nest: Enter Cloud Project ID", @@ -29,7 +29,7 @@ "title": "Configure Cloud Pub/Sub topic", "description": "Nest devices publish updates on a Cloud Pub/Sub topic. You can select an existing topic if one exists, or choose to create a new topic and the next step will create it for you with the necessary permissions. See the integration documentation for [more info]({more_info_url}).", "data": { - "topic_name": "Pub/Sub topic Name" + "topic_name": "Pub/Sub topic name" } }, "pubsub_topic_confirm": { @@ -41,12 +41,15 @@ "title": "Configure Cloud Pub/Sub subscription", "description": "Home Assistant receives realtime Nest device updates with a Cloud Pub/Sub subscription for topic `{topic}`.\n\nSelect an existing subscription below if one already exists, or the next step will create a new one for you. See the integration documentation for [more info]({more_info_url}).", "data": { - "subscription_name": "Pub/Sub subscription Name" + "subscription_name": "Pub/Sub subscription name" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Nest integration needs to re-authenticate your account" + }, + "oauth_discovery": { + "description": "Home Assistant has found a Google Nest device on your network. Be aware that the set up of Google Nest is more complicated than most other integrations and requires setting up a Nest Device Access project which **requires paying Google a US $5 fee**. Press **Submit** to continue setting up Google Nest." } }, "error": { diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 283ccc3740e..0164d673619 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -236,7 +236,7 @@ class NetatmoDataHandler: **self.publisher[signal_name].kwargs ) - except (pyatmo.NoDevice, pyatmo.ApiError) as err: + except (pyatmo.NoDeviceError, pyatmo.ApiError) as err: _LOGGER.debug(err) has_error = True diff --git a/homeassistant/components/netatmo/diagnostics.py b/homeassistant/components/netatmo/diagnostics.py index 4901ef6bd55..8cb07d1f9d8 100644 --- a/homeassistant/components/netatmo/diagnostics.py +++ b/homeassistant/components/netatmo/diagnostics.py @@ -49,7 +49,7 @@ async def async_get_config_entry_diagnostics( ), "data": { ACCOUNT: async_redact_data( - getattr(data_handler.account, "raw_data"), + data_handler.account.raw_data, TO_REDACT, ) }, diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 6fdebcf0c3f..b519c75ae55 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -178,7 +178,8 @@ class NetatmoWeatherModuleEntity(NetatmoModuleEntity): def __init__(self, device: NetatmoDevice) -> None: """Set up a Netatmo weather module entity.""" super().__init__(device) - category = getattr(self.device.device_category, "name") + assert self.device.device_category + category = self.device.device_category.name self._publishers.extend( [ { @@ -189,7 +190,7 @@ class NetatmoWeatherModuleEntity(NetatmoModuleEntity): ) if hasattr(self.device, "place"): - place = cast(Place, getattr(self.device, "place")) + place = cast(Place, self.device.place) if hasattr(place, "location") and place.location is not None: self._attr_extra_state_attributes.update( { diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 84c8be1d0be..13beb1330e4 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==9.0.0"] + "requirements": ["pyatmo==9.2.0"] } diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index e8637c90584..cb6675e4129 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -72,7 +72,9 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): self._attr_unique_id = f"{self.home.entity_id}-schedule-select" - self._attr_current_option = getattr(self.home.get_selected_schedule(), "name") + schedule = self.home.get_selected_schedule() + assert schedule + self._attr_current_option = schedule.name self._attr_options = [ schedule.name for schedule in self.home.schedules.values() if schedule.name ] @@ -98,12 +100,11 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): return if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data: - self._attr_current_option = getattr( + self._attr_current_option = ( self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get( data["schedule_id"] - ), - "name", - ) + ) + ).name self.async_write_ha_state() async def async_select_option(self, option: str) -> None: @@ -125,7 +126,9 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): @callback def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_current_option = getattr(self.home.get_selected_schedule(), "name") + schedule = self.home.get_selected_schedule() + assert schedule + self._attr_current_option = schedule.name self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id] = ( self.home.schedules ) diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index dd8468df099..712475b9b34 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -41,8 +41,8 @@ class NetgearSwitchEntityDescriptionRequired: class NetgearSwitchEntityDescription(SwitchEntityDescription): """Class describing Netgear Switch entities.""" - update: Callable[[NetgearRouter], bool] - action: Callable[[NetgearRouter], bool] + update: Callable[[NetgearRouter], Callable[[], bool | None]] + action: Callable[[NetgearRouter], Callable[[bool], bool]] ROUTER_SWITCH_TYPES = [ @@ -200,12 +200,12 @@ class NetgearRouterSwitchEntity(NetgearRouterEntity, SwitchEntity): self._attr_is_on = None self._attr_available = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Fetch state when entity is added.""" await self.async_update() await super().async_added_to_hass() - async def async_update(self): + async def async_update(self) -> None: """Poll the state of the switch.""" async with self._router.api_lock: response = await self.hass.async_add_executor_job( @@ -217,14 +217,14 @@ class NetgearRouterSwitchEntity(NetgearRouterEntity, SwitchEntity): self._attr_is_on = response self._attr_available = True - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" async with self._router.api_lock: await self.hass.async_add_executor_job( self.entity_description.action(self._router), True ) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" async with self._router.api_lock: await self.hass.async_add_executor_job( diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index e9637a16ae0..52ff87e11c7 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -34,7 +34,6 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import VolDictType from .const import ( @@ -42,7 +41,6 @@ from .const import ( ATTR_DEHUMIDIFY_SETPOINT, ATTR_HUMIDIFY_SETPOINT, ATTR_RUN_MODE, - DOMAIN, ) from .coordinator import NexiaDataUpdateCoordinator from .entity import NexiaThermostatZoneEntity @@ -183,8 +181,6 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): self._attr_supported_features = NEXIA_SUPPORTED if self._has_humidify_support or self._has_dehumidify_support: self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY - if self._has_emergency_heat: - self._attr_supported_features |= ClimateEntityFeature.AUX_HEAT self._attr_preset_modes = zone.get_presets() self._attr_fan_modes = thermostat.get_fan_modes() self._attr_hvac_modes = HVAC_MODES @@ -387,11 +383,6 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): ) self._signal_zone_update() - @property - def is_aux_heat(self) -> bool: - """Emergency heat state.""" - return self._thermostat.is_emergency_heat_active() - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" @@ -414,36 +405,6 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): await self._zone.set_preset(preset_mode) self._signal_zone_update() - async def async_turn_aux_heat_off(self) -> None: - """Turn Aux Heat off.""" - async_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, - ) - await self._thermostat.set_emergency_heat(False) - self._signal_thermostat_update() - - async def async_turn_aux_heat_on(self) -> None: - """Turn Aux Heat on.""" - async_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, - ) - await self._thermostat.set_emergency_heat(True) - self._signal_thermostat_update() - async def async_turn_off(self) -> None: """Turn off the zone.""" await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index e8a1b53cc08..939b0b62284 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.7.0"] + "requirements": ["nexia==2.10.0"] } diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index f6b08d5e8e5..d8ec2112fe4 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -65,6 +65,9 @@ "hold": { "name": "Hold" }, + "room_iq_sensor": { + "name": "Include {sensor_name}" + }, "emergency_heat": { "name": "Emergency heat" } @@ -115,18 +118,5 @@ } } } - }, - "issues": { - "migrate_aux_heat": { - "title": "Migration of Nexia set_aux_heat action", - "fix_flow": { - "step": { - "confirm": { - "description": "The Nexia `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new Emergency heat switch entity. When this is done, select **Submit** to fix this issue.", - "title": "[%key:component::nexia::issues::migrate_aux_heat::title%]" - } - } - } - } } } diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py index 1897ad67414..bf1495217a7 100644 --- a/homeassistant/components/nexia/switch.py +++ b/homeassistant/components/nexia/switch.py @@ -2,14 +2,19 @@ from __future__ import annotations +from collections.abc import Iterable +import functools as ft from typing import Any from nexia.const import OPERATION_MODE_OFF +from nexia.roomiq import NexiaRoomIQHarmonizer +from nexia.sensor import NexiaSensor from nexia.thermostat import NexiaThermostat from nexia.zone import NexiaThermostatZone from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NexiaDataUpdateCoordinator @@ -17,6 +22,14 @@ from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity from .types import NexiaConfigEntry +async def _stop_harmonizers( + _: Event, harmonizers: Iterable[NexiaRoomIQHarmonizer] +) -> None: + """Run the shutdown methods when preparing to stop.""" + for harmonizer in harmonizers: + await harmonizer.async_shutdown() # Never suspends + + async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, @@ -25,7 +38,8 @@ async def async_setup_entry( """Set up switches for a Nexia device.""" coordinator = config_entry.runtime_data nexia_home = coordinator.nexia_home - entities: list[NexiaHoldSwitch | NexiaEmergencyHeatSwitch] = [] + entities: list[SwitchEntity] = [] + room_iq_zones: dict[int, NexiaRoomIQHarmonizer] = {} for thermostat_id in nexia_home.get_thermostat_ids(): thermostat: NexiaThermostat = nexia_home.get_thermostat_by_id(thermostat_id) if thermostat.has_emergency_heat(): @@ -33,8 +47,18 @@ async def async_setup_entry( for zone_id in thermostat.get_zone_ids(): zone: NexiaThermostatZone = thermostat.get_zone_by_id(zone_id) entities.append(NexiaHoldSwitch(coordinator, zone)) + if len(zone_sensors := zone.get_sensors()) > 1: + entities.extend( + NexiaRoomIQSwitch(coordinator, zone, sensor, room_iq_zones) + for sensor in zone_sensors + ) async_add_entities(entities) + if room_iq_zones: + listener = ft.partial(_stop_harmonizers, harmonizers=room_iq_zones.values()) + config_entry.async_on_unload( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, listener) + ) class NexiaHoldSwitch(NexiaThermostatZoneEntity, SwitchEntity): @@ -68,6 +92,49 @@ class NexiaHoldSwitch(NexiaThermostatZoneEntity, SwitchEntity): self._signal_zone_update() +class NexiaRoomIQSwitch(NexiaThermostatZoneEntity, SwitchEntity): + """Provides Nexia RoomIQ sensor switch support.""" + + _attr_translation_key = "room_iq_sensor" + + def __init__( + self, + coordinator: NexiaDataUpdateCoordinator, + zone: NexiaThermostatZone, + sensor: NexiaSensor, + room_iq_zones: dict[int, NexiaRoomIQHarmonizer], + ) -> None: + """Initialize the RoomIQ sensor switch.""" + super().__init__(coordinator, zone, f"{sensor.id}_room_iq_sensor") + self._attr_translation_placeholders = {"sensor_name": sensor.name} + self._sensor_id = sensor.id + if zone.zone_id in room_iq_zones: + self._harmonizer = room_iq_zones[zone.zone_id] + else: + self._harmonizer = NexiaRoomIQHarmonizer( + zone, coordinator.async_refresh, self._signal_zone_update + ) + room_iq_zones[zone.zone_id] = self._harmonizer + + @property + def is_on(self) -> bool: + """Return if the sensor is part of the zone average temperature.""" + if self._harmonizer.request_pending(): + return self._sensor_id in self._harmonizer.selected_sensor_ids + + return self._zone.get_sensor_by_id(self._sensor_id).weight > 0.0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Include this sensor.""" + self._harmonizer.trigger_add_sensor(self._sensor_id) + self._signal_zone_update() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Remove this sensor.""" + self._harmonizer.trigger_remove_sensor(self._sensor_id) + self._signal_zone_update() + + class NexiaEmergencyHeatSwitch(NexiaThermostatEntity, SwitchEntity): """Provides Nexia emergency heat switch support.""" diff --git a/homeassistant/components/nextbus/coordinator.py b/homeassistant/components/nextbus/coordinator.py index 617669adf2f..e8d7ab06915 100644 --- a/homeassistant/components/nextbus/coordinator.py +++ b/homeassistant/components/nextbus/coordinator.py @@ -1,8 +1,8 @@ """NextBus data update coordinator.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging -from typing import Any +from typing import Any, override from py_nextbus import NextBusClient from py_nextbus.client import NextBusFormatError, NextBusHTTPError @@ -15,8 +15,14 @@ from .util import RouteStop _LOGGER = logging.getLogger(__name__) +# At what percentage of the request limit should the coordinator pause making requests +UPDATE_INTERVAL_SECONDS = 30 +THROTTLE_PRECENTAGE = 80 -class NextBusDataUpdateCoordinator(DataUpdateCoordinator): + +class NextBusDataUpdateCoordinator( + DataUpdateCoordinator[dict[RouteStop, dict[str, Any]]] +): """Class to manage fetching NextBus data.""" def __init__(self, hass: HomeAssistant, agency: str) -> None: @@ -26,7 +32,7 @@ class NextBusDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER, config_entry=None, # It is shared between multiple entries name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), ) self.client = NextBusClient(agency_id=agency) self._agency = agency @@ -49,9 +55,26 @@ class NextBusDataUpdateCoordinator(DataUpdateCoordinator): """Check if this coordinator is tracking any routes.""" return len(self._route_stops) > 0 - async def _async_update_data(self) -> dict[str, Any]: + @override + async def _async_update_data(self) -> dict[RouteStop, dict[str, Any]]: """Fetch data from NextBus.""" + if ( + # If we have predictions, check the rate limit + self._predictions + # If are over our rate limit percentage, we should throttle + and self.client.rate_limit_percent >= THROTTLE_PRECENTAGE + # But only if we have a reset time to unthrottle + and self.client.rate_limit_reset is not None + # Unless we are after the reset time + and datetime.now() < self.client.rate_limit_reset + ): + self.logger.debug( + "Rate limit threshold reached. Skipping updates for. Routes: %s", + str(self._route_stops), + ) + return self._predictions + _stops_to_route_stops: dict[str, set[RouteStop]] = {} for route_stop in self._route_stops: _stops_to_route_stops.setdefault(route_stop.stop_id, set()).add(route_stop) @@ -60,7 +83,7 @@ class NextBusDataUpdateCoordinator(DataUpdateCoordinator): "Updating data from API. Routes: %s", str(_stops_to_route_stops) ) - def _update_data() -> dict: + def _update_data() -> dict[RouteStop, dict[str, Any]]: """Fetch data from NextBus.""" self.logger.debug("Updating data from API (executor)") predictions: dict[RouteStop, dict[str, Any]] = {} diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index a4f6d54f58c..4b7057f7142 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.1.2"] + "requirements": ["py-nextbusnext==2.2.0"] } diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index ef4e3de0f62..75950e94211 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -259,7 +259,7 @@ "name": "Task updates" }, "nextcloud_system_apps_app_updates_twofactor_totp": { - "name": "Two factor authentication updates" + "name": "Two-factor authentication updates" }, "nextcloud_system_apps_num_installed": { "name": "Apps installed" diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index b0a2d12b004..853fae342f4 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -110,11 +110,11 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity): if action.is_dimmable: self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} - self._attr_brightness = round(action.state * 2.55) + self._attr_brightness = action.state async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - await self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) + await self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255)) async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -125,4 +125,4 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity): state = self._action.state self._attr_is_on = state > 0 if brightness_supported(self.supported_color_modes): - self._attr_brightness = round(state * 2.55) + self._attr_brightness = state diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 83fca0ca2d6..1193d33d435 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.4.10"] + "requirements": ["nhc==0.4.12"] } diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 8bb9a347373..7383bd5932a 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.5"], + "requirements": ["pynina==0.3.6"], "single_config_entry": true } diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index 3cbbea007b1..5605ce82ac3 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -23,9 +23,9 @@ "user": { "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP addresses (192.168.1.1), IP networks (192.168.0.0/24) or IP ranges (192.168.1.0-32).", "data": { - "hosts": "Network addresses (comma separated) to scan", + "hosts": "Network addresses (comma-separated) to scan", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", - "exclude": "Network addresses (comma separated) to exclude from scanning", + "exclude": "Network addresses (comma-separated) to exclude from scanning", "scan_options": "Raw configurable scan options for Nmap" } } diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 771da420213..018f3e2b06a 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -40,7 +40,7 @@ SUPPORT_FLAGS = ( PRESET_MODES = [PRESET_NONE, PRESET_COMFORT, PRESET_ECO, PRESET_AWAY] MIN_TEMPERATURE = 7 -MAX_TEMPERATURE = 40 +MAX_TEMPERATURE = 30 async def async_setup_entry( diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index b096d2bd506..ca299b470ea 100644 --- a/homeassistant/components/nordpool/manifest.json +++ b/homeassistant/components/nordpool/manifest.json @@ -8,6 +8,6 @@ "iot_class": "cloud_polling", "loggers": ["pynordpool"], "quality_scale": "platinum", - "requirements": ["pynordpool==0.2.4"], + "requirements": ["pynordpool==0.3.0"], "single_config_entry": true } diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index 6607edfdbcb..628962811e3 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -97,11 +97,8 @@ def async_setup_services(hass: HomeAssistant) -> None: translation_domain=DOMAIN, translation_key="authentication_error", ) from error - except NordPoolEmptyResponseError as error: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="empty_response", - ) from error + except NordPoolEmptyResponseError: + return {area: [] for area in areas} except NordPoolError as error: raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 7b33f032de1..73c35673826 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -129,9 +129,6 @@ "authentication_error": { "message": "There was an authentication error as you tried to retrieve data too far in the past." }, - "empty_response": { - "message": "Nord Pool has not posted market prices for the provided date." - }, "connection_error": { "message": "There was a connection error connecting to the API. Try again later." } diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index fde1569d622..d9d864d10a3 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["aionfty"], "quality_scale": "bronze", - "requirements": ["aiontfy==0.5.2"] + "requirements": ["aiontfy==0.5.3"] } diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index a48d158c896..13704d960be 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -18,7 +18,7 @@ "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.", + "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. Home Assistant will automatically generate an access token to authenticate with ntfy.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -51,7 +51,7 @@ "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}**" + "account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**" } }, "config_subentries": { diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 376a07ddb7b..85e24c116f9 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -130,7 +130,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): return HVACAction.HEATING if self._thermostat.heating else HVACAction.IDLE @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum supported temperature for the thermostat.""" if self._temperature_unit == "C": return self._thermostat.min_celsius @@ -138,7 +138,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): return self._thermostat.min_fahrenheit @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum supported temperature for the thermostat.""" if self._temperature_unit == "C": return self._thermostat.max_celsius diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 2785c46ca17..4bdc2a15156 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData -from .const import DOMAIN as NUKI_DOMAIN +from .const import DOMAIN from .entity import NukiEntity @@ -25,7 +25,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki binary sensors.""" - entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] + entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] entities: list[NukiEntity] = [] diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 4f3890a10cf..809e97d6ce9 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData -from .const import DOMAIN as NUKI_DOMAIN +from .const import DOMAIN from .entity import NukiEntity @@ -21,7 +21,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock sensor.""" - entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] + entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] async_add_entities( NukiBatterySensor(entry_data.coordinator, lock) for lock in entry_data.locks diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 280edb819d4..1b41146cd2a 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, DEGREE, @@ -33,6 +34,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPrecipitationDepth, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfReactivePower, UnitOfSoundPressure, UnitOfSpeed, @@ -44,6 +46,7 @@ from homeassistant.const import ( ) from homeassistant.util.unit_conversion import ( BaseUnitConverter, + ReactiveEnergyConverter, TemperatureConverter, VolumeFlowRateConverter, ) @@ -174,7 +177,7 @@ class NumberDeviceClass(StrEnum): Use this device class for sensors measuring energy by distance, for example the amount of electric energy consumed by an electric car. - Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + Unit of measurement: `kWh/100km`, `Wh/km`, `mi/kWh`, `km/kWh` """ ENERGY_STORAGE = "energy_storage" @@ -196,7 +199,7 @@ class NumberDeviceClass(StrEnum): """Gas. Unit of measurement: - - SI / metric: `m³` + - SI / metric: `L`, `m³` - USCS / imperial: `ft³`, `CCF` """ @@ -320,6 +323,12 @@ class NumberDeviceClass(StrEnum): - `psi` """ + REACTIVE_ENERGY = "reactive_energy" + """Reactive energy. + + Unit of measurement: `varh`, `kvarh` + """ + REACTIVE_POWER = "reactive_power" """Reactive power. @@ -362,7 +371,7 @@ class NumberDeviceClass(StrEnum): VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" """Amount of VOC. - Unit of measurement: `µg/m³` + Unit of measurement: `µg/m³`, `mg/m³` """ VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" @@ -472,6 +481,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, }, NumberDeviceClass.HUMIDITY: {PERCENTAGE}, NumberDeviceClass.ILLUMINANCE: {LIGHT_LUX}, @@ -497,6 +507,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_ENERGY: set(UnitOfReactiveEnergy), NumberDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), NumberDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, @@ -507,7 +518,8 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.TEMPERATURE: set(UnitOfTemperature), NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, }, NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: { CONCENTRATION_PARTS_PER_BILLION, @@ -530,6 +542,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { } UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = { + NumberDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter, NumberDeviceClass.TEMPERATURE: TemperatureConverter, NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter, } diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index 49103f5cd41..dcce09984bd 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -111,6 +111,9 @@ "pressure": { "default": "mdi:gauge" }, + "reactive_energy": { + "default": "mdi:lightning-bolt" + }, "reactive_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 993120ef3ad..998b9ffba38 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -130,6 +130,9 @@ "pressure": { "name": "[%key:component::sensor::entity_component::pressure::name%]" }, + "reactive_energy": { + "name": "[%key:component::sensor::entity_component::reactive_energy::name%]" + }, "reactive_power": { "name": "[%key:component::sensor::entity_component::reactive_power::name%]" }, diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index ae20ed39251..2f2c6badc4c 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -187,7 +187,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool async def async_remove_config_entry_device( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NutConfigEntry, device_entry: dr.DeviceEntry, ) -> bool: """Remove NUT config entry from a device.""" diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index ce8c10f8f41..11b646f86a1 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -610,6 +610,33 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "outlet.current": SensorEntityDescription( + key="outlet.current", + translation_key="outlet_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "outlet.power": SensorEntityDescription( + key="outlet.power", + translation_key="outlet_power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "outlet.realpower": SensorEntityDescription( + key="outlet.realpower", + translation_key="outlet_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "outlet.voltage": SensorEntityDescription( key="outlet.voltage", translation_key="outlet_voltage", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index a9a3b470cca..8f993d5fbb1 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -240,6 +240,9 @@ "outlet_number_desc": { "name": "Outlet {outlet_name} description" }, "outlet_number_power": { "name": "Outlet {outlet_name} power" }, "outlet_number_realpower": { "name": "Outlet {outlet_name} real power" }, + "outlet_current": { "name": "Outlet current" }, + "outlet_power": { "name": "Outlet apparent power" }, + "outlet_realpower": { "name": "Outlet real power" }, "outlet_voltage": { "name": "Outlet voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index ab9e05b5fbe..6c507030ad3 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -89,9 +89,11 @@ def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: def _convert_content( - chat_content: conversation.Content - | conversation.ToolResultContent - | conversation.AssistantContent, + chat_content: ( + conversation.Content + | conversation.ToolResultContent + | conversation.AssistantContent + ), ) -> ollama.Message: """Create tool response content.""" if isinstance(chat_content, conversation.ToolResultContent): @@ -172,6 +174,7 @@ class OllamaConversationEntity( """Ollama conversation agent.""" _attr_has_entity_name = True + _attr_supports_streaming = True def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" diff --git a/homeassistant/components/omnilogic/switch.py b/homeassistant/components/omnilogic/switch.py index a9f8bc77d8a..9583194f41b 100644 --- a/homeassistant/components/omnilogic/switch.py +++ b/homeassistant/components/omnilogic/switch.py @@ -92,12 +92,12 @@ class OmniLogicSwitch(OmniLogicEntity, SwitchEntity): ) self._state_key = state_key - self._state = None - self._last_action = 0 + self._state: bool | None = None + self._last_action = 0.0 self._state_delay = 30 @property - def is_on(self): + def is_on(self) -> bool: """Return the on/off state of the switch.""" state_int = 0 @@ -119,7 +119,7 @@ class OmniLogicSwitch(OmniLogicEntity, SwitchEntity): class OmniLogicRelayControl(OmniLogicSwitch): """Define the OmniLogic Relay entity.""" - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the relay.""" self._state = True self._last_action = time.time() @@ -132,7 +132,7 @@ class OmniLogicRelayControl(OmniLogicSwitch): 1, ) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the relay.""" self._state = False self._last_action = time.time() @@ -178,7 +178,7 @@ class OmniLogicPumpControl(OmniLogicSwitch): self._last_speed = None - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the pump.""" self._state = True self._last_action = time.time() @@ -196,7 +196,7 @@ class OmniLogicPumpControl(OmniLogicSwitch): on_value, ) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the pump.""" self._state = False self._last_action = time.time() diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index fbe64492b3c..6d3f461981c 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -177,7 +177,9 @@ class OpenAIOptionsFlow(OptionsFlow): options = { CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], + CONF_PROMPT: user_input.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ), CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), } diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 67e79e270d7..a129400194b 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -141,6 +141,11 @@ async def _transform_stream( if isinstance(event.item, ResponseOutputMessage): yield {"role": event.item.role} elif isinstance(event.item, ResponseFunctionToolCall): + # OpenAI has tool calls as individual events + # while HA puts tool calls inside the assistant message. + # We turn them into individual assistant content for HA + # to ensure that tools are called as soon as possible. + yield {"role": "assistant"} current_tool_call = event.item elif isinstance(event, ResponseOutputItemDoneEvent): item = event.item.model_dump() @@ -226,6 +231,7 @@ class OpenAIConversationEntity( _attr_has_entity_name = True _attr_name = None + _attr_supports_streaming = True def __init__(self, entry: OpenAIConfigEntry) -> None: """Initialize the agent.""" @@ -269,7 +275,7 @@ class OpenAIConversationEntity( user_input: conversation.ConversationInput, chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: - """Call the API.""" + """Process the user input and call the API.""" options = self.entry.options try: @@ -282,6 +288,24 @@ class OpenAIConversationEntity( except conversation.ConverseError as err: return err.as_conversation_result() + await self._async_handle_chat_log(chat_log) + + intent_response = intent.IntentResponse(language=user_input.language) + assert type(chat_log.content[-1]) is conversation.AssistantContent + intent_response.async_set_speech(chat_log.content[-1].content or "") + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.entry.options + tools: list[ToolParam] | None = None if chat_log.llm_api: tools = [ @@ -352,7 +376,7 @@ 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, messages) + self.entity_id, _transform_stream(chat_log, result, messages) ): if not isinstance(content, conversation.AssistantContent): messages.extend(_convert_content_to_param(content)) @@ -360,15 +384,6 @@ class OpenAIConversationEntity( if not chat_log.unresponded_tool_results: break - intent_response = intent.IntentResponse(language=user_input.language) - assert type(chat_log.content[-1]) is conversation.AssistantContent - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index 2bdf9947fe2..f541ee0b515 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -6,6 +6,7 @@ import asyncio from base64 import b64encode from http import HTTPStatus import logging +from typing import Any import aiohttp import voluptuous as vol @@ -72,7 +73,8 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the OpenALPR cloud API platform.""" - confidence = config[CONF_CONFIDENCE] + confidence: float = config[CONF_CONFIDENCE] + source: list[dict[str, str]] = config[CONF_SOURCE] params = { "secret_key": config[CONF_API_KEY], "tasks": "plate", @@ -84,7 +86,7 @@ async def async_setup_platform( OpenAlprCloudEntity( camera[CONF_ENTITY_ID], params, confidence, camera.get(CONF_NAME) ) - for camera in config[CONF_SOURCE] + for camera in source ) @@ -99,10 +101,10 @@ class ImageProcessingAlprEntity(ImageProcessingEntity): self.vehicles = 0 @property - def state(self): + def state(self) -> str | None: """Return the state of the entity.""" - confidence = 0 - plate = None + confidence = 0.0 + plate: str | None = None # search high plate for i_pl, i_co in self.plates.items(): @@ -112,7 +114,7 @@ class ImageProcessingAlprEntity(ImageProcessingEntity): return plate @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" return {ATTR_PLATES: self.plates, ATTR_VEHICLES: self.vehicles} @@ -156,35 +158,26 @@ class ImageProcessingAlprEntity(ImageProcessingEntity): class OpenAlprCloudEntity(ImageProcessingAlprEntity): """Representation of an OpenALPR cloud entity.""" - def __init__(self, camera_entity, params, confidence, name=None): + def __init__( + self, + camera_entity: str, + params: dict[str, Any], + confidence: float, + name: str | None, + ) -> None: """Initialize OpenALPR cloud API.""" super().__init__() self._params = params - self._camera = camera_entity - self._confidence = confidence + self._attr_camera_entity = camera_entity + self._attr_confidence = confidence if name: - self._name = name + self._attr_name = name else: - self._name = f"OpenAlpr {split_entity_id(camera_entity)[1]}" + self._attr_name = f"OpenAlpr {split_entity_id(camera_entity)[1]}" - @property - def confidence(self): - """Return minimum confidence for send events.""" - return self._confidence - - @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - async def async_process_image(self, image): + async def async_process_image(self, image: bytes) -> None: """Process image. This method is a coroutine. diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index ae1a1eb9276..5d35311b69a 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -379,7 +379,7 @@ }, "set_central_heating_ovrd": { "name": "Set central heating override", - "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a 'Set control set point' action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the 'Set control set point' action with temperature value 0. You will only need this if you are writing your own software thermostat.", + "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a 'Set control setpoint' action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the 'Set control setpoint' action with temperature value 0. You will only need this if you are writing your own software thermostat.", "fields": { "gateway_id": { "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", @@ -410,7 +410,7 @@ } }, "set_control_setpoint": { - "name": "Set control set point", + "name": "Set control setpoint", "description": "Sets the central heating control setpoint override on the gateway. You will only need this if you are writing your own software thermostat.", "fields": { "gateway_id": { @@ -438,7 +438,7 @@ } }, "set_hot_water_setpoint": { - "name": "Set hot water set point", + "name": "Set hot water setpoint", "description": "Sets the domestic hot water setpoint on the gateway.", "fields": { "gateway_id": { diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index f45404ce38e..09c9ab75192 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -49,6 +49,10 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): @callback def _handle_coordinator_update(self) -> None: """Update the entity from the latest data.""" + self._update_attrs() + super()._handle_coordinator_update() + + def _update_attrs(self) -> None: data = self.coordinator.data for key in ("from_time", "to_time", "from_uv", "to_uv"): @@ -78,5 +82,3 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): ATTR_PROTECTION_WINDOW_STARTING_TIME: as_local(from_dt), } ) - - super()._handle_coordinator_update() diff --git a/homeassistant/components/openuv/entity.py b/homeassistant/components/openuv/entity.py index f3015815bf1..2303f21f2b8 100644 --- a/homeassistant/components/openuv/entity.py +++ b/homeassistant/components/openuv/entity.py @@ -31,3 +31,8 @@ class OpenUvEntity(CoordinatorEntity): name="OpenUV", entry_type=DeviceEntryType.SERVICE, ) + + self._update_attrs() + + def _update_attrs(self) -> None: + """Override point for updating attributes during init.""" diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 40ddf0ff37e..737e4fb8e4f 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAM from homeassistant.core import HomeAssistant from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS -from .coordinator import WeatherUpdateCoordinator +from .coordinator import OWMUpdateCoordinator, get_owm_update_coordinator from .repairs import async_create_issue, async_delete_issue from .utils import build_data_and_options @@ -27,7 +27,7 @@ class OpenweathermapData: name: str mode: str - coordinator: WeatherUpdateCoordinator + coordinator: OWMUpdateCoordinator async def async_setup_entry( @@ -45,13 +45,13 @@ async def async_setup_entry( async_delete_issue(hass, entry.entry_id) owm_client = create_owm_client(api_key, mode, lang=language) - weather_coordinator = WeatherUpdateCoordinator(hass, entry, owm_client) + owm_coordinator = get_owm_update_coordinator(mode)(hass, entry, owm_client) - await weather_coordinator.async_config_entry_first_refresh() + await owm_coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(async_update_options)) - entry.runtime_data = OpenweathermapData(name, mode, weather_coordinator) + entry.runtime_data = OpenweathermapData(name, mode, owm_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index fbd2cb1aee2..9ede24ed1af 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -51,21 +51,28 @@ ATTR_API_CURRENT = "current" ATTR_API_MINUTE_FORECAST = "minute_forecast" ATTR_API_HOURLY_FORECAST = "hourly_forecast" ATTR_API_DAILY_FORECAST = "daily_forecast" +ATTR_API_AIRPOLLUTION_AQI = "aqi" +ATTR_API_AIRPOLLUTION_CO = "co" +ATTR_API_AIRPOLLUTION_NO = "no" +ATTR_API_AIRPOLLUTION_NO2 = "no2" +ATTR_API_AIRPOLLUTION_O3 = "o3" +ATTR_API_AIRPOLLUTION_SO2 = "so2" +ATTR_API_AIRPOLLUTION_PM2_5 = "pm2_5" +ATTR_API_AIRPOLLUTION_PM10 = "pm10" +ATTR_API_AIRPOLLUTION_NH3 = "nh3" + UPDATE_LISTENER = "update_listener" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] -FORECAST_MODE_HOURLY = "hourly" -FORECAST_MODE_DAILY = "daily" -FORECAST_MODE_FREE_DAILY = "freedaily" -FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly" -FORECAST_MODE_ONECALL_DAILY = "onecall_daily" OWM_MODE_FREE_CURRENT = "current" OWM_MODE_FREE_FORECAST = "forecast" OWM_MODE_V30 = "v3.0" +OWM_MODE_AIRPOLLUTION = "air_pollution" OWM_MODES = [ OWM_MODE_V30, OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST, + OWM_MODE_AIRPOLLUTION, ] DEFAULT_OWM_MODE = OWM_MODE_V30 diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py index 994949b5e03..614bf3f193a 100644 --- a/homeassistant/components/openweathermap/coordinator.py +++ b/homeassistant/components/openweathermap/coordinator.py @@ -1,12 +1,13 @@ -"""Weather data coordinator for the OpenWeatherMap (OWM) service.""" +"""Data coordinator for the OpenWeatherMap (OWM) service.""" from __future__ import annotations from datetime import timedelta import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from pyopenweathermap import ( + CurrentAirPollution, CurrentWeather, DailyWeatherForecast, HourlyWeatherForecast, @@ -31,6 +32,15 @@ if TYPE_CHECKING: from . import OpenweathermapConfigEntry from .const import ( + ATTR_API_AIRPOLLUTION_AQI, + ATTR_API_AIRPOLLUTION_CO, + ATTR_API_AIRPOLLUTION_NH3, + ATTR_API_AIRPOLLUTION_NO, + ATTR_API_AIRPOLLUTION_NO2, + ATTR_API_AIRPOLLUTION_O3, + ATTR_API_AIRPOLLUTION_PM2_5, + ATTR_API_AIRPOLLUTION_PM10, + ATTR_API_AIRPOLLUTION_SO2, ATTR_API_CLOUDS, ATTR_API_CONDITION, ATTR_API_CURRENT, @@ -57,16 +67,20 @@ from .const import ( ATTR_API_WIND_SPEED, CONDITION_MAP, DOMAIN, + OWM_MODE_AIRPOLLUTION, + OWM_MODE_FREE_CURRENT, + OWM_MODE_FREE_FORECAST, + OWM_MODE_V30, WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT, ) _LOGGER = logging.getLogger(__name__) -WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) +OWM_UPDATE_INTERVAL = timedelta(minutes=10) -class WeatherUpdateCoordinator(DataUpdateCoordinator): - """Weather data update coordinator.""" +class OWMUpdateCoordinator(DataUpdateCoordinator): + """OWM data update coordinator.""" config_entry: OpenweathermapConfigEntry @@ -86,9 +100,13 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=WEATHER_UPDATE_INTERVAL, + update_interval=OWM_UPDATE_INTERVAL, ) + +class WeatherUpdateCoordinator(OWMUpdateCoordinator): + """Weather data update coordinator.""" + async def _async_update_data(self): """Update the data.""" try: @@ -248,3 +266,52 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return ATTR_CONDITION_CLEAR_NIGHT return CONDITION_MAP.get(weather_code) + + +class AirPollutionUpdateCoordinator(OWMUpdateCoordinator): + """Air Pollution data update coordinator.""" + + async def _async_update_data(self) -> dict[str, Any]: + """Update the data.""" + try: + air_pollution_report = await self._owm_client.get_air_pollution( + self._latitude, self._longitude + ) + except RequestError as error: + raise UpdateFailed(error) from error + current_air_pollution = ( + self._get_current_air_pollution_data(air_pollution_report.current) + if air_pollution_report.current is not None + else {} + ) + + return { + ATTR_API_CURRENT: current_air_pollution, + } + + def _get_current_air_pollution_data( + self, current_air_pollution: CurrentAirPollution + ) -> dict[str, Any]: + return { + ATTR_API_AIRPOLLUTION_AQI: current_air_pollution.aqi, + ATTR_API_AIRPOLLUTION_CO: current_air_pollution.co, + ATTR_API_AIRPOLLUTION_NO: current_air_pollution.no, + ATTR_API_AIRPOLLUTION_NO2: current_air_pollution.no2, + ATTR_API_AIRPOLLUTION_O3: current_air_pollution.o3, + ATTR_API_AIRPOLLUTION_SO2: current_air_pollution.so2, + ATTR_API_AIRPOLLUTION_PM2_5: current_air_pollution.pm2_5, + ATTR_API_AIRPOLLUTION_PM10: current_air_pollution.pm10, + ATTR_API_AIRPOLLUTION_NH3: current_air_pollution.nh3, + } + + +def get_owm_update_coordinator(mode: str) -> type[OWMUpdateCoordinator]: + """Create coordinator with a factory.""" + coordinators = { + OWM_MODE_V30: WeatherUpdateCoordinator, + OWM_MODE_FREE_CURRENT: WeatherUpdateCoordinator, + OWM_MODE_FREE_FORECAST: WeatherUpdateCoordinator, + OWM_MODE_AIRPOLLUTION: AirPollutionUpdateCoordinator, + } + + return coordinators[mode] diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json index 88510aaae8c..2c32882b6ed 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -1,7 +1,7 @@ { "domain": "openweathermap", "name": "OpenWeatherMap", - "codeowners": ["@fabaff", "@freekode", "@nzapponi"], + "codeowners": ["@fabaff", "@freekode", "@nzapponi", "@wittypluck"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openweathermap", "iot_class": "cloud_polling", diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index a595652d90b..87b7860afb5 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, DEGREE, PERCENTAGE, UV_INDEX, @@ -23,10 +24,17 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import OpenweathermapConfigEntry from .const import ( + ATTR_API_AIRPOLLUTION_AQI, + ATTR_API_AIRPOLLUTION_CO, + ATTR_API_AIRPOLLUTION_NO, + ATTR_API_AIRPOLLUTION_NO2, + ATTR_API_AIRPOLLUTION_O3, + ATTR_API_AIRPOLLUTION_PM2_5, + ATTR_API_AIRPOLLUTION_PM10, + ATTR_API_AIRPOLLUTION_SO2, ATTR_API_CLOUDS, ATTR_API_CONDITION, ATTR_API_CURRENT, @@ -45,12 +53,12 @@ from .const import ( ATTR_API_WIND_BEARING, ATTR_API_WIND_SPEED, ATTRIBUTION, - DEFAULT_NAME, DOMAIN, MANUFACTURER, + OWM_MODE_AIRPOLLUTION, OWM_MODE_FREE_FORECAST, ) -from .coordinator import WeatherUpdateCoordinator +from .coordinator import OWMUpdateCoordinator WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -153,6 +161,56 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), ) +AIRPOLLUTION_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_API_AIRPOLLUTION_AQI, + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_AIRPOLLUTION_CO, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_AIRPOLLUTION_NO, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.NITROGEN_MONOXIDE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_AIRPOLLUTION_NO2, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_AIRPOLLUTION_O3, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.OZONE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_AIRPOLLUTION_SO2, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_AIRPOLLUTION_PM2_5, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_AIRPOLLUTION_PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -162,7 +220,9 @@ async def async_setup_entry( """Set up OpenWeatherMap sensor entities based on a config entry.""" domain_data = config_entry.runtime_data name = domain_data.name - weather_coordinator = domain_data.coordinator + unique_id = config_entry.unique_id + assert unique_id is not None + coordinator = domain_data.coordinator if domain_data.mode == OWM_MODE_FREE_FORECAST: entity_registry = er.async_get(hass) @@ -171,13 +231,23 @@ async def async_setup_entry( ) for entry in entries: entity_registry.async_remove(entry.entity_id) + elif domain_data.mode == OWM_MODE_AIRPOLLUTION: + async_add_entities( + OpenWeatherMapSensor( + name, + unique_id, + description, + coordinator, + ) + for description in AIRPOLLUTION_SENSOR_TYPES + ) else: async_add_entities( OpenWeatherMapSensor( name, - f"{config_entry.unique_id}-{description.key}", + unique_id, description, - weather_coordinator, + coordinator, ) for description in WEATHER_SENSOR_TYPES ) @@ -188,26 +258,25 @@ class AbstractOpenWeatherMapSensor(SensorEntity): _attr_should_poll = False _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, name: str, unique_id: str, description: SensorEntityDescription, - coordinator: DataUpdateCoordinator, + coordinator: OWMUpdateCoordinator, ) -> None: """Initialize the sensor.""" self.entity_description = description self._coordinator = coordinator - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = unique_id - split_unique_id = unique_id.split("-") + self._attr_unique_id = f"{unique_id}-{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"{split_unique_id[0]}-{split_unique_id[1]}")}, + identifiers={(DOMAIN, unique_id)}, manufacturer=MANUFACTURER, - name=DEFAULT_NAME, + name=name, ) @property @@ -229,20 +298,7 @@ class AbstractOpenWeatherMapSensor(SensorEntity): class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor): """Implementation of an OpenWeatherMap sensor.""" - def __init__( - self, - name: str, - unique_id: str, - description: SensorEntityDescription, - weather_coordinator: WeatherUpdateCoordinator, - ) -> None: - """Initialize the sensor.""" - super().__init__(name, unique_id, description, weather_coordinator) - self._weather_coordinator = weather_coordinator - @property def native_value(self) -> StateType: """Return the state of the device.""" - return self._weather_coordinator.data[ATTR_API_CURRENT].get( - self.entity_description.key - ) + return self._coordinator.data[ATTR_API_CURRENT].get(self.entity_description.key) diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 12d883c871a..f182b083b90 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -41,10 +41,11 @@ from .const import ( DEFAULT_NAME, DOMAIN, MANUFACTURER, + OWM_MODE_AIRPOLLUTION, OWM_MODE_FREE_FORECAST, OWM_MODE_V30, ) -from .coordinator import WeatherUpdateCoordinator +from .coordinator import OWMUpdateCoordinator SERVICE_GET_MINUTE_FORECAST = "get_minute_forecast" @@ -58,27 +59,31 @@ async def async_setup_entry( domain_data = config_entry.runtime_data name = domain_data.name mode = domain_data.mode - weather_coordinator = domain_data.coordinator - unique_id = f"{config_entry.unique_id}" - owm_weather = OpenWeatherMapWeather(name, unique_id, mode, weather_coordinator) + if mode != OWM_MODE_AIRPOLLUTION: + weather_coordinator = domain_data.coordinator - async_add_entities([owm_weather], False) + unique_id = f"{config_entry.unique_id}" + owm_weather = OpenWeatherMapWeather(name, unique_id, mode, weather_coordinator) - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - name=SERVICE_GET_MINUTE_FORECAST, - schema=None, - func="async_get_minute_forecast", - supports_response=SupportsResponse.ONLY, - ) + async_add_entities([owm_weather], False) + + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + name=SERVICE_GET_MINUTE_FORECAST, + schema=None, + func="async_get_minute_forecast", + supports_response=SupportsResponse.ONLY, + ) -class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): +class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator]): """Implementation of an OpenWeatherMap sensor.""" _attr_attribution = ATTRIBUTION _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA @@ -91,17 +96,16 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina name: str, unique_id: str, mode: str, - weather_coordinator: WeatherUpdateCoordinator, + weather_coordinator: OWMUpdateCoordinator, ) -> None: """Initialize the sensor.""" super().__init__(weather_coordinator) - self._attr_name = name self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, unique_id)}, manufacturer=MANUFACTURER, - name=DEFAULT_NAME, + name=name, ) self.mode = mode diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index beaf63ad59d..0aa26dbb4b1 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.12.1"] + "requirements": ["opower==0.12.3"] } diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index f65aeb011ee..3af968cf789 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -9,7 +9,7 @@ } }, "mfa": { - "description": "The TOTP secret below is not one of the 6 digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", + "description": "The TOTP secret below is not one of the 6-digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", "data": { "totp_secret": "TOTP secret" } diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 25380810862..42af6c74e45 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -279,7 +279,7 @@ class Luminary(LightEntity): return self._device_attributes @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 7ccbbb69aa1..22762cb390d 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -1,5 +1,7 @@ """Device tracker platform that adds support for OwnTracks over MQTT.""" +from typing import Any + from homeassistant.components.device_tracker import ( ATTR_SOURCE_TYPE, DOMAIN as DEVICE_TRACKER_DOMAIN, @@ -19,7 +21,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import DOMAIN as OT_DOMAIN +from . import DOMAIN async def async_setup_entry( @@ -38,22 +40,22 @@ async def async_setup_entry( entities = [] for dev_id in dev_ids: - entity = hass.data[OT_DOMAIN]["devices"][dev_id] = OwnTracksEntity(dev_id) + entity = hass.data[DOMAIN]["devices"][dev_id] = OwnTracksEntity(dev_id) entities.append(entity) @callback def _receive_data(dev_id, **data): """Receive set location.""" - entity = hass.data[OT_DOMAIN]["devices"].get(dev_id) + entity = hass.data[DOMAIN]["devices"].get(dev_id) if entity is not None: entity.update_data(data) return - entity = hass.data[OT_DOMAIN]["devices"][dev_id] = OwnTracksEntity(dev_id, data) + entity = hass.data[DOMAIN]["devices"][dev_id] = OwnTracksEntity(dev_id, data) async_add_entities([entity]) - hass.data[OT_DOMAIN]["context"].set_async_see(_receive_data) + hass.data[DOMAIN]["context"].set_async_see(_receive_data) async_add_entities(entities) @@ -64,34 +66,34 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, dev_id, data=None): + def __init__(self, dev_id: str, data: dict[str, Any] | None = None) -> None: """Set up OwnTracks entity.""" self._dev_id = dev_id self._data = data or {} self.entity_id = f"{DEVICE_TRACKER_DOMAIN}.{dev_id}" @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID.""" return self._dev_id @property - def battery_level(self): + def battery_level(self) -> int | None: """Return the battery level of the device.""" return self._data.get("battery") @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return device specific attributes.""" return self._data.get("attributes") @property - def location_accuracy(self): + def location_accuracy(self) -> float: """Return the gps accuracy of the device.""" - return self._data.get("gps_accuracy") + return self._data.get("gps_accuracy", 0) @property - def latitude(self): + def latitude(self) -> float | None: """Return latitude value of the device.""" # Check with "get" instead of "in" because value can be None if self._data.get("gps"): @@ -100,7 +102,7 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): return None @property - def longitude(self): + def longitude(self) -> float | None: """Return longitude value of the device.""" # Check with "get" instead of "in" because value can be None if self._data.get("gps"): @@ -109,7 +111,7 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): return None @property - def location_name(self): + def location_name(self) -> str | None: """Return a location name for the current location of the device.""" return self._data.get("location_name") @@ -121,7 +123,7 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): @property def device_info(self) -> DeviceInfo: """Return the device info.""" - device_info = DeviceInfo(identifiers={(OT_DOMAIN, self._dev_id)}) + device_info = DeviceInfo(identifiers={(DOMAIN, self._dev_id)}) if "host_name" in self._data: device_info["name"] = self._data["host_name"] return device_info diff --git a/homeassistant/components/pandora/__init__.py b/homeassistant/components/pandora/__init__.py index 9664730bdab..0850b00553e 100644 --- a/homeassistant/components/pandora/__init__.py +++ b/homeassistant/components/pandora/__init__.py @@ -1 +1,3 @@ """The pandora component.""" + +DOMAIN = "pandora" diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index 0b2f5b7055f..77564245522 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -27,10 +27,13 @@ from homeassistant.const import ( SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) @@ -53,6 +56,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Pandora media player platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Pandora", + }, + ) + if not _pianobar_exists(): return pandora = PandoraMediaPlayer("Pandora") @@ -94,18 +112,22 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._attr_media_duration = 0 self._pianobar: pexpect.spawn[str] | None = None - def turn_on(self) -> None: - """Turn the media player on.""" - if self.state != MediaPlayerState.OFF: - return - self._pianobar = pexpect.spawn("pianobar", encoding="utf-8") + async def _start_pianobar(self) -> bool: + pianobar = pexpect.spawn("pianobar", encoding="utf-8") + pianobar.delaybeforesend = None + # mypy thinks delayafterread must be a float but that is not what pexpect says + # https://github.com/pexpect/pexpect/blob/4.9/pexpect/expect.py#L170 + pianobar.delayafterread = None # type: ignore[assignment] + pianobar.delayafterclose = 0 + pianobar.delayafterterminate = 0 _LOGGER.debug("Started pianobar subprocess") - mode = self._pianobar.expect( - ["Receiving new playlist", "Select station:", "Email:"] + mode = await pianobar.expect( + ["Receiving new playlist", "Select station:", "Email:"], + async_=True, ) if mode == 1: # station list was presented. dismiss it. - self._pianobar.sendcontrol("m") + pianobar.sendcontrol("m") elif mode == 2: _LOGGER.warning( "The pianobar client is not configured to log in. " @@ -113,16 +135,20 @@ class PandoraMediaPlayer(MediaPlayerEntity): "https://www.home-assistant.io/integrations/pandora/" ) # pass through the email/password prompts to quit cleanly - self._pianobar.sendcontrol("m") - self._pianobar.sendcontrol("m") - self._pianobar.terminate() - self._pianobar = None - return - self._update_stations() - self.update_playing_status() + pianobar.sendcontrol("m") + pianobar.sendcontrol("m") + pianobar.terminate() + return False + self._pianobar = pianobar + return True - self._attr_state = MediaPlayerState.IDLE - self.schedule_update_ha_state() + async def async_turn_on(self) -> None: + """Turn the media player on.""" + if self.state == MediaPlayerState.OFF and await self._start_pianobar(): + await self._update_stations() + await self.update_playing_status() + self._attr_state = MediaPlayerState.IDLE + self.schedule_update_ha_state() def turn_off(self) -> None: """Turn the media player off.""" @@ -142,30 +168,24 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._attr_state = MediaPlayerState.OFF self.schedule_update_ha_state() - def media_play(self) -> None: + async def async_media_play(self) -> None: """Send play command.""" - self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) + await self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) self._attr_state = MediaPlayerState.PLAYING self.schedule_update_ha_state() - def media_pause(self) -> None: + async def async_media_pause(self) -> None: """Send pause command.""" - self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) + await self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) self._attr_state = MediaPlayerState.PAUSED self.schedule_update_ha_state() - def media_next_track(self) -> None: + async def async_media_next_track(self) -> None: """Go to next track.""" - self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK) + await self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK) self.schedule_update_ha_state() - @property - def media_title(self) -> str | None: - """Title of current playing media.""" - self.update_playing_status() - return self._attr_media_title - - def select_source(self, source: str) -> None: + async def async_select_source(self, source: str) -> None: """Choose a different Pandora station and play it.""" if self.source_list is None: return @@ -176,45 +196,46 @@ class PandoraMediaPlayer(MediaPlayerEntity): return _LOGGER.debug("Setting station %s, %d", source, station_index) assert self._pianobar is not None - self._send_station_list_command() + await self._send_station_list_command() self._pianobar.sendline(f"{station_index}") - self._pianobar.expect("\r\n") + await self._pianobar.expect("\r\n", async_=True) self._attr_state = MediaPlayerState.PLAYING - def _send_station_list_command(self) -> None: + async def _send_station_list_command(self) -> None: """Send a station list command.""" assert self._pianobar is not None self._pianobar.send("s") try: - self._pianobar.expect("Select station:", timeout=1) + await self._pianobar.expect("Select station:", async_=True, timeout=1) except pexpect.exceptions.TIMEOUT: # try again. Buffer was contaminated. - self._clear_buffer() + await self._clear_buffer() self._pianobar.send("s") - self._pianobar.expect("Select station:") + await self._pianobar.expect("Select station:", async_=True) - def update_playing_status(self) -> None: + async def update_playing_status(self) -> None: """Query pianobar for info about current media_title, station.""" - response = self._query_for_playing_status() + response = await self._query_for_playing_status() if not response: return self._update_current_station(response) self._update_current_song(response) self._update_song_position() - def _query_for_playing_status(self) -> str | None: + async def _query_for_playing_status(self) -> str | None: """Query system for info about current track.""" assert self._pianobar is not None - self._clear_buffer() + await self._clear_buffer() self._pianobar.send("i") try: - match_idx = self._pianobar.expect( + match_idx = await self._pianobar.expect( [ r"(\d\d):(\d\d)/(\d\d):(\d\d)", "No song playing", "Select station", "Receiving new playlist", - ] + ], + async_=True, ) except pexpect.exceptions.EOF: _LOGGER.warning("Pianobar process already exited") @@ -229,11 +250,11 @@ class PandoraMediaPlayer(MediaPlayerEntity): _LOGGER.warning("On unexpected station list page") self._pianobar.sendcontrol("m") # press enter self._pianobar.sendcontrol("m") # do it again b/c an 'i' got in - self.update_playing_status() + await self.update_playing_status() return None if match_idx == 3: _LOGGER.debug("Received new playlist list") - self.update_playing_status() + await self.update_playing_status() return None return self._pianobar.before @@ -292,7 +313,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): repr(self._pianobar.after), ) - def _send_pianobar_command(self, service_cmd: str) -> None: + async def _send_pianobar_command(self, service_cmd: str) -> None: """Send a command to Pianobar.""" assert self._pianobar is not None command = CMD_MAP.get(service_cmd) @@ -300,13 +321,13 @@ class PandoraMediaPlayer(MediaPlayerEntity): if command is None: _LOGGER.warning("Command %s not supported yet", service_cmd) return - self._clear_buffer() + await self._clear_buffer() self._pianobar.sendline(command) - def _update_stations(self) -> None: + async def _update_stations(self) -> None: """List defined Pandora stations.""" assert self._pianobar is not None - self._send_station_list_command() + await self._send_station_list_command() station_lines = self._pianobar.before or "" _LOGGER.debug("Getting stations: %s", station_lines) self._attr_source_list = [] @@ -320,7 +341,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._pianobar.sendcontrol("m") # press enter with blank line self._pianobar.sendcontrol("m") # do it twice in case an 'i' got in - def _clear_buffer(self) -> None: + async def _clear_buffer(self) -> None: """Clear buffer from pexpect. This is necessary because there are a bunch of 00:00 in the buffer @@ -328,7 +349,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): """ assert self._pianobar is not None try: - while not self._pianobar.expect(".+", timeout=0.1): + while not await self._pianobar.expect(".+", async_=True, timeout=0.1): pass except pexpect.exceptions.TIMEOUT: pass diff --git a/homeassistant/components/paperless_ngx/__init__.py b/homeassistant/components/paperless_ngx/__init__.py new file mode 100644 index 00000000000..c6147d5ff95 --- /dev/null +++ b/homeassistant/components/paperless_ngx/__init__.py @@ -0,0 +1,104 @@ +"""The Paperless-ngx integration.""" + +from pypaperless import Paperless +from pypaperless.exceptions import ( + InitializationError, + PaperlessConnectionError, + PaperlessForbiddenError, + PaperlessInactiveOrDeletedError, + PaperlessInvalidTokenError, +) + +from homeassistant.const import CONF_API_KEY, CONF_URL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER +from .coordinator import ( + PaperlessConfigEntry, + PaperlessData, + PaperlessStatisticCoordinator, + PaperlessStatusCoordinator, +) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> bool: + """Set up Paperless-ngx from a config entry.""" + + api = await _get_paperless_api(hass, entry) + + statistics_coordinator = PaperlessStatisticCoordinator(hass, entry, api) + status_coordinator = PaperlessStatusCoordinator(hass, entry, api) + + await statistics_coordinator.async_config_entry_first_refresh() + + try: + await status_coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady as err: + # Catch the error so the integration doesn't fail just because status coordinator fails. + LOGGER.warning("Could not initialize status coordinator: %s", err) + + entry.runtime_data = PaperlessData( + status=status_coordinator, + statistics=statistics_coordinator, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> bool: + """Unload paperless-ngx config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _get_paperless_api( + hass: HomeAssistant, + entry: PaperlessConfigEntry, +) -> Paperless: + """Create and initialize paperless-ngx API.""" + + api = Paperless( + entry.data[CONF_URL], + entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + + try: + await api.initialize() + await api.statistics() # test permissions on api + except PaperlessConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except PaperlessInvalidTokenError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err + except PaperlessInactiveOrDeletedError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="user_inactive_or_deleted", + ) from err + except PaperlessForbiddenError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="forbidden", + ) from err + except InitializationError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + else: + return api diff --git a/homeassistant/components/paperless_ngx/config_flow.py b/homeassistant/components/paperless_ngx/config_flow.py new file mode 100644 index 00000000000..c0c1dc4ce19 --- /dev/null +++ b/homeassistant/components/paperless_ngx/config_flow.py @@ -0,0 +1,151 @@ +"""Config flow for the Paperless-ngx integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pypaperless import Paperless +from pypaperless.exceptions import ( + InitializationError, + PaperlessConnectionError, + PaperlessForbiddenError, + PaperlessInactiveOrDeletedError, + PaperlessInvalidTokenError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_API_KEY): str, + } +) + + +class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Paperless-ngx.""" + + 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_URL: user_input[CONF_URL], + CONF_API_KEY: user_input[CONF_API_KEY], + } + ) + + errors = await self._validate_input(user_input) + + if not errors: + return self.async_create_entry( + title=user_input[CONF_URL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure flow for Paperless-ngx integration.""" + + entry = self._get_reconfigure_entry() + + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_API_KEY: user_input[CONF_API_KEY], + } + ) + + errors = await self._validate_input(user_input) + + if not errors: + return self.async_update_reload_and_abort(entry, data=user_input) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values={ + CONF_URL: user_input[CONF_URL] + if user_input is not None + else entry.data[CONF_URL], + }, + ), + errors=errors, + ) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reauth flow for Paperless-ngx integration.""" + + entry = self._get_reauth_entry() + + errors: dict[str, str] = {} + if user_input is not None: + updated_data = {**entry.data, CONF_API_KEY: user_input[CONF_API_KEY]} + + errors = await self._validate_input(updated_data) + + if not errors: + return self.async_update_reload_and_abort( + entry, + data=updated_data, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def _validate_input(self, user_input: dict[str, str]) -> dict[str, str]: + errors: dict[str, str] = {} + + client = Paperless( + user_input[CONF_URL], + user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + + try: + await client.initialize() + await client.statistics() # test permissions on api + except PaperlessConnectionError: + errors[CONF_URL] = "cannot_connect" + except PaperlessInvalidTokenError: + errors[CONF_API_KEY] = "invalid_api_key" + except PaperlessInactiveOrDeletedError: + errors[CONF_API_KEY] = "user_inactive_or_deleted" + except PaperlessForbiddenError: + errors[CONF_API_KEY] = "forbidden" + except InitializationError: + errors[CONF_URL] = "cannot_connect" + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected exception: %s", err) + errors["base"] = "unknown" + + return errors diff --git a/homeassistant/components/paperless_ngx/const.py b/homeassistant/components/paperless_ngx/const.py new file mode 100644 index 00000000000..67e569510eb --- /dev/null +++ b/homeassistant/components/paperless_ngx/const.py @@ -0,0 +1,7 @@ +"""Constants for the Paperless-ngx integration.""" + +import logging + +DOMAIN = "paperless_ngx" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/paperless_ngx/coordinator.py b/homeassistant/components/paperless_ngx/coordinator.py new file mode 100644 index 00000000000..d5960bed49b --- /dev/null +++ b/homeassistant/components/paperless_ngx/coordinator.py @@ -0,0 +1,139 @@ +"""Paperless-ngx Status coordinator.""" + +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass +from datetime import timedelta +from typing import TypeVar + +from pypaperless import Paperless +from pypaperless.exceptions import ( + PaperlessConnectionError, + PaperlessForbiddenError, + PaperlessInactiveOrDeletedError, + PaperlessInvalidTokenError, +) +from pypaperless.models import Statistic, Status + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +type PaperlessConfigEntry = ConfigEntry[PaperlessData] + +TData = TypeVar("TData") + +UPDATE_INTERVAL_STATISTICS = timedelta(seconds=120) +UPDATE_INTERVAL_STATUS = timedelta(seconds=300) + + +@dataclass +class PaperlessData: + """Data for the Paperless-ngx integration.""" + + statistics: PaperlessStatisticCoordinator + status: PaperlessStatusCoordinator + + +class PaperlessCoordinator(DataUpdateCoordinator[TData]): + """Coordinator to manage fetching Paperless-ngx API.""" + + config_entry: PaperlessConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: PaperlessConfigEntry, + api: Paperless, + name: str, + update_interval: timedelta, + ) -> None: + """Initialize Paperless-ngx statistics coordinator.""" + self.api = api + + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=name, + update_interval=update_interval, + ) + + async def _async_update_data(self) -> TData: + """Update data via internal method.""" + try: + return await self._async_update_data_internal() + except PaperlessConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except PaperlessInvalidTokenError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err + except PaperlessInactiveOrDeletedError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="user_inactive_or_deleted", + ) from err + except PaperlessForbiddenError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="forbidden", + ) from err + + @abstractmethod + async def _async_update_data_internal(self) -> TData: + """Update data via paperless-ngx API.""" + + +class PaperlessStatisticCoordinator(PaperlessCoordinator[Statistic]): + """Coordinator to manage Paperless-ngx statistic updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: PaperlessConfigEntry, + api: Paperless, + ) -> None: + """Initialize Paperless-ngx status coordinator.""" + super().__init__( + hass, + entry, + api, + name="Statistics Coordinator", + update_interval=UPDATE_INTERVAL_STATISTICS, + ) + + async def _async_update_data_internal(self) -> Statistic: + """Fetch statistics data from API endpoint.""" + return await self.api.statistics() + + +class PaperlessStatusCoordinator(PaperlessCoordinator[Status]): + """Coordinator to manage Paperless-ngx status updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: PaperlessConfigEntry, + api: Paperless, + ) -> None: + """Initialize Paperless-ngx status coordinator.""" + super().__init__( + hass, + entry, + api, + name="Status Coordinator", + update_interval=UPDATE_INTERVAL_STATUS, + ) + + async def _async_update_data_internal(self) -> Status: + """Fetch status data from API endpoint.""" + return await self.api.status() diff --git a/homeassistant/components/paperless_ngx/diagnostics.py b/homeassistant/components/paperless_ngx/diagnostics.py new file mode 100644 index 00000000000..3222295d055 --- /dev/null +++ b/homeassistant/components/paperless_ngx/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics support for Paperless-ngx.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import PaperlessConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: PaperlessConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "data": { + "statistics": asdict(entry.runtime_data.statistics.data), + "status": asdict(entry.runtime_data.status.data), + }, + } diff --git a/homeassistant/components/paperless_ngx/entity.py b/homeassistant/components/paperless_ngx/entity.py new file mode 100644 index 00000000000..e7eb0f0edcf --- /dev/null +++ b/homeassistant/components/paperless_ngx/entity.py @@ -0,0 +1,38 @@ +"""Paperless-ngx base entity.""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from homeassistant.components.sensor import EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PaperlessCoordinator + +TCoordinator = TypeVar("TCoordinator", bound=PaperlessCoordinator) + + +class PaperlessEntity(CoordinatorEntity[TCoordinator], Generic[TCoordinator]): + """Defines a base Paperless-ngx entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the Paperless-ngx entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="Paperless-ngx", + sw_version=coordinator.api.host_version, + configuration_url=coordinator.api.base_url, + ) diff --git a/homeassistant/components/paperless_ngx/icons.json b/homeassistant/components/paperless_ngx/icons.json new file mode 100644 index 00000000000..1df7a7d701c --- /dev/null +++ b/homeassistant/components/paperless_ngx/icons.json @@ -0,0 +1,81 @@ +{ + "entity": { + "sensor": { + "documents_total": { + "default": "mdi:file-document-multiple" + }, + "documents_inbox": { + "default": "mdi:tray-full" + }, + "characters_count": { + "default": "mdi:alphabet-latin" + }, + "tag_count": { + "default": "mdi:tag" + }, + "correspondent_count": { + "default": "mdi:account-group" + }, + "storage_total": { + "default": "mdi:harddisk" + }, + "storage_available": { + "default": "mdi:harddisk" + }, + "database_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "index_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "classifier_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "celery_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "redis_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + }, + "sanity_check_status": { + "default": "mdi:check-circle", + "state": { + "ok": "mdi:check-circle", + "warning": "mdi:alert", + "error": "mdi:alert-circle", + "unknown": "mdi:help-circle" + } + } + } + } +} diff --git a/homeassistant/components/paperless_ngx/manifest.json b/homeassistant/components/paperless_ngx/manifest.json new file mode 100644 index 00000000000..0be3562c76f --- /dev/null +++ b/homeassistant/components/paperless_ngx/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "paperless_ngx", + "name": "Paperless-ngx", + "codeowners": ["@fvgarrel"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/paperless_ngx", + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["pypaperless"], + "quality_scale": "silver", + "requirements": ["pypaperless==4.1.0"] +} diff --git a/homeassistant/components/paperless_ngx/quality_scale.yaml b/homeassistant/components/paperless_ngx/quality_scale.yaml new file mode 100644 index 00000000000..827d4425132 --- /dev/null +++ b/homeassistant/components/paperless_ngx/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register actions yet. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register actions yet. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not register custom events yet. + 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: Integration does not register actions yet. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow yet + 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: Paperless does not support discovery. + discovery: + status: exempt + comment: Paperless does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Service type integration + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: todo + stale-devices: + status: exempt + comment: Service type integration + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/paperless_ngx/sensor.py b/homeassistant/components/paperless_ngx/sensor.py new file mode 100644 index 00000000000..e3f601b68e6 --- /dev/null +++ b/homeassistant/components/paperless_ngx/sensor.py @@ -0,0 +1,269 @@ +"""Sensor platform for Paperless-ngx.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from pypaperless.models import Statistic, Status +from pypaperless.models.common import StatusType + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.unit_conversion import InformationConverter + +from .coordinator import ( + PaperlessConfigEntry, + PaperlessStatisticCoordinator, + PaperlessStatusCoordinator, + TData, +) +from .entity import PaperlessEntity, TCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PaperlessEntityDescription(SensorEntityDescription, Generic[TData]): + """Describes Paperless-ngx sensor entity.""" + + value_fn: Callable[[TData], StateType] + + +SENSOR_STATISTICS: tuple[PaperlessEntityDescription, ...] = ( + PaperlessEntityDescription[Statistic]( + key="documents_total", + translation_key="documents_total", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.documents_total, + ), + PaperlessEntityDescription[Statistic]( + key="documents_inbox", + translation_key="documents_inbox", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.documents_inbox, + ), + PaperlessEntityDescription[Statistic]( + key="characters_count", + translation_key="characters_count", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.character_count, + ), + PaperlessEntityDescription[Statistic]( + key="tag_count", + translation_key="tag_count", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.tag_count, + ), + PaperlessEntityDescription[Statistic]( + key="correspondent_count", + translation_key="correspondent_count", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.correspondent_count, + ), + PaperlessEntityDescription[Statistic]( + key="document_type_count", + translation_key="document_type_count", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.document_type_count, + ), +) + +SENSOR_STATUS: tuple[PaperlessEntityDescription, ...] = ( + PaperlessEntityDescription[Status]( + key="storage_total", + translation_key="storage_total", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=( + lambda data: round( + InformationConverter().convert( + data.storage.total, + UnitOfInformation.BYTES, + UnitOfInformation.GIGABYTES, + ), + 2, + ) + if data.storage is not None and data.storage.total is not None + else None + ), + ), + PaperlessEntityDescription[Status]( + key="storage_available", + translation_key="storage_available", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=( + lambda data: round( + InformationConverter().convert( + data.storage.available, + UnitOfInformation.BYTES, + UnitOfInformation.GIGABYTES, + ), + 2, + ) + if data.storage is not None and data.storage.available is not None + else None + ), + ), + PaperlessEntityDescription[Status]( + key="database_status", + translation_key="database_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.database.status.value.lower() + if ( + data.database is not None + and data.database.status is not None + and data.database.status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="index_status", + translation_key="index_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.index_status.value.lower() + if ( + data.tasks is not None + and data.tasks.index_status is not None + and data.tasks.index_status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="classifier_status", + translation_key="classifier_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.classifier_status.value.lower() + if ( + data.tasks is not None + and data.tasks.classifier_status is not None + and data.tasks.classifier_status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="celery_status", + translation_key="celery_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.celery_status.value.lower() + if ( + data.tasks is not None + and data.tasks.celery_status is not None + and data.tasks.celery_status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="redis_status", + translation_key="redis_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.redis_status.value.lower() + if ( + data.tasks is not None + and data.tasks.redis_status is not None + and data.tasks.redis_status != StatusType.UNKNOWN + ) + else None + ), + ), + PaperlessEntityDescription[Status]( + key="sanity_check_status", + translation_key="sanity_check_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + ], + value_fn=( + lambda data: data.tasks.sanity_check_status.value.lower() + if ( + data.tasks is not None + and data.tasks.sanity_check_status is not None + and data.tasks.sanity_check_status != StatusType.UNKNOWN + ) + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PaperlessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Paperless-ngx sensors.""" + + entities: list[PaperlessSensor] = [] + + entities += [ + PaperlessSensor[PaperlessStatisticCoordinator]( + coordinator=entry.runtime_data.statistics, + description=description, + ) + for description in SENSOR_STATISTICS + ] + + entities += [ + PaperlessSensor[PaperlessStatusCoordinator]( + coordinator=entry.runtime_data.status, + description=description, + ) + for description in SENSOR_STATUS + ] + + async_add_entities(entities) + + +class PaperlessSensor(PaperlessEntity[TCoordinator], SensorEntity): + """Defines a Paperless-ngx sensor entity.""" + + entity_description: PaperlessEntityDescription + + @property + def native_value(self) -> StateType: + """Return the current value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/paperless_ngx/strings.json b/homeassistant/components/paperless_ngx/strings.json new file mode 100644 index 00000000000..33d806463d1 --- /dev/null +++ b/homeassistant/components/paperless_ngx/strings.json @@ -0,0 +1,148 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "URL to connect to the Paperless-ngx instance", + "api_key": "API key to connect to the Paperless-ngx API" + }, + "title": "Add Paperless-ngx instance" + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]" + }, + "title": "Re-auth Paperless-ngx instance" + }, + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "[%key:component::paperless_ngx::config::step::user::data_description::url%]", + "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]" + }, + "title": "Reconfigure Paperless-ngx instance" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::invalid_host%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "user_inactive_or_deleted": "Authentication failed. The user is inactive or has been deleted.", + "forbidden": "The token does not have permission to access the API.", + "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%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "entity": { + "sensor": { + "documents_total": { + "name": "Total documents", + "unit_of_measurement": "documents" + }, + "documents_inbox": { + "name": "Documents in inbox", + "unit_of_measurement": "[%key:component::paperless_ngx::entity::sensor::documents_total::unit_of_measurement%]" + }, + "characters_count": { + "name": "Total characters", + "unit_of_measurement": "characters" + }, + "tag_count": { + "name": "Tags", + "unit_of_measurement": "tags" + }, + "correspondent_count": { + "name": "Correspondents", + "unit_of_measurement": "correspondents" + }, + "document_type_count": { + "name": "Document types", + "unit_of_measurement": "document types" + }, + "storage_total": { + "name": "Total storage" + }, + "storage_available": { + "name": "Available storage" + }, + "database_status": { + "name": "Status database", + "state": { + "ok": "OK", + "warning": "Warning", + "error": "[%key:common::state::error%]" + } + }, + "index_status": { + "name": "Status index", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } + }, + "classifier_status": { + "name": "Status classifier", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } + }, + "celery_status": { + "name": "Status Celery", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } + }, + "redis_status": { + "name": "Status Redis", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } + }, + "sanity_check_status": { + "name": "Status sanity", + "state": { + "ok": "[%key:component::paperless_ngx::entity::sensor::database_status::state::ok%]", + "warning": "[%key:component::paperless_ngx::entity::sensor::database_status::state::warning%]", + "error": "[%key:common::state::error%]" + } + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::invalid_host%]" + }, + "invalid_api_key": { + "message": "[%key:common::config_flow::error::invalid_api_key%]" + }, + "user_inactive_or_deleted": { + "message": "[%key:component::paperless_ngx::config::error::user_inactive_or_deleted%]" + }, + "forbidden": { + "message": "[%key:component::paperless_ngx::config::error::forbidden%]" + }, + "unknown": { + "message": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/peco/strings.json b/homeassistant/components/peco/strings.json index cdf5bb497db..c4683056dd7 100644 --- a/homeassistant/components/peco/strings.json +++ b/homeassistant/components/peco/strings.json @@ -4,7 +4,7 @@ "user": { "data": { "county": "County", - "phone_number": "Phone Number" + "phone_number": "Phone number" }, "data_description": { "county": "County used for outage number retrieval", diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index 251964c15d0..e7623c5eb03 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/picnic", "iot_class": "cloud_polling", "loggers": ["python_picnic_api2"], - "requirements": ["python-picnic-api2==1.2.4"] + "requirements": ["python-picnic-api2==1.3.1"] } diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 385acbe4818..8da2e171cef 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -59,7 +59,7 @@ def setup_platform( config[CONF_SOURCES], ) - if pioneer.update(): + if pioneer.update_device(): add_entities([pioneer]) @@ -122,7 +122,11 @@ class PioneerDevice(MediaPlayerEntity): except telnetlib.socket.timeout: _LOGGER.debug("Pioneer %s command %s timed out", self._name, command) - def update(self): + def update(self) -> None: + """Update the entity.""" + self.update_device() + + def update_device(self) -> bool: """Get the latest details from the device.""" try: telnet = telnetlib.Telnet(self._host, self._port, self._timeout) diff --git a/homeassistant/components/plaato/strings.json b/homeassistant/components/plaato/strings.json index 3fb593a9c73..66c5d18e0e7 100644 --- a/homeassistant/components/plaato/strings.json +++ b/homeassistant/components/plaato/strings.json @@ -14,7 +14,7 @@ "description": "To be able to query the API an 'auth token' is required which can be obtained by following [these instructions]({auth_token_url})\n\nSelected device: **{device_type}** \n\nIf you prefer to use the built-in webhook method (Airlock only) please check the box below and leave 'Auth token' blank", "data": { "use_webhook": "Use webhook", - "token": "Paste Auth Token here" + "token": "Auth token" } }, "webhook": { diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 4a1654959f6..ed96adeff8a 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -7,6 +7,7 @@ from functools import wraps import logging from typing import Any, Concatenate, cast +from plexapi.client import PlexClient import plexapi.exceptions import requests.exceptions @@ -189,7 +190,7 @@ class PlexMediaPlayer(MediaPlayerEntity): PLEX_UPDATE_SENSOR_SIGNAL.format(self.plex_server.machine_identifier), ) - def update(self): + def update(self) -> None: """Refresh key device data.""" if not self.session: self.force_idle() @@ -207,6 +208,7 @@ class PlexMediaPlayer(MediaPlayerEntity): self.device.proxyThroughServer() self._device_protocol_capabilities = self.device.protocolCapabilities + device: PlexClient for device in filter(None, [self.device, self.session_device]): self.device_make = self.device_make or device.device self.device_platform = self.device_platform or device.platform diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index b346f26492c..4ed100b538d 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -99,12 +99,10 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData translation_key="unsupported_firmware", ) from err - self._async_add_remove_devices(data, self.config_entry) + self._async_add_remove_devices(data) return data - def _async_add_remove_devices( - self, data: dict[str, GwEntityData], entry: ConfigEntry - ) -> None: + def _async_add_remove_devices(self, data: dict[str, GwEntityData]) -> None: """Add new Plugwise devices, remove non-existing devices.""" # Check for new or removed devices self.new_devices = set(data) - self._current_devices @@ -112,11 +110,9 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData self._current_devices = set(data) if removed_devices: - self._async_remove_devices(data, entry) + self._async_remove_devices(data) - def _async_remove_devices( - self, data: dict[str, GwEntityData], entry: ConfigEntry - ) -> None: + def _async_remove_devices(self, data: dict[str, GwEntityData]) -> None: """Clean registries when removed devices found.""" device_reg = dr.async_get(self.hass) device_list = dr.async_entries_for_config_entry( @@ -136,7 +132,8 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData and identifier[1] not in data ): device_reg.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id + device_entry.id, + remove_config_entry_id=self.config_entry.entry_id, ) LOGGER.debug( "Removed %s device %s %s from device_registry", diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 3f812c1a63b..264afd79ed2 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.3"], + "requirements": ["plugwise==1.7.4"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index d26e70d1c4f..fdbe8c39015 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -23,7 +23,7 @@ }, "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." } diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index fa56bf70546..2df26283624 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PointConfigEntry -from .const import DOMAIN as POINT_DOMAIN, SIGNAL_WEBHOOK +from .const import DOMAIN, SIGNAL_WEBHOOK _LOGGER = logging.getLogger(__name__) @@ -62,7 +62,7 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): self._attr_name = self._home["name"] self._attr_unique_id = f"point.{home_id}" self._attr_device_info = DeviceInfo( - identifiers={(POINT_DOMAIN, home_id)}, + identifiers={(DOMAIN, home_id)}, manufacturer="Minut", name=self._attr_name, ) diff --git a/homeassistant/components/powerfox/const.py b/homeassistant/components/powerfox/const.py index 0970e8a1b66..790f241ae8e 100644 --- a/homeassistant/components/powerfox/const.py +++ b/homeassistant/components/powerfox/const.py @@ -8,4 +8,4 @@ from typing import Final DOMAIN: Final = "powerfox" LOGGER = logging.getLogger(__package__) -SCAN_INTERVAL = timedelta(minutes=1) +SCAN_INTERVAL = timedelta(seconds=10) diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index f242d2c67e6..b44fea05638 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -314,7 +314,7 @@ class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Direction Energy sensor.""" - _attr_state_class = SensorStateClass.TOTAL + _attr_state_class = SensorStateClass.TOTAL_INCREASING _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR _attr_device_class = SensorDeviceClass.ENERGY diff --git a/homeassistant/components/probe_plus/__init__.py b/homeassistant/components/probe_plus/__init__.py new file mode 100644 index 00000000000..be1faf4a297 --- /dev/null +++ b/homeassistant/components/probe_plus/__init__.py @@ -0,0 +1,24 @@ +"""The Probe Plus integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import ProbePlusConfigEntry, ProbePlusDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ProbePlusConfigEntry) -> bool: + """Set up Probe Plus from a config entry.""" + coordinator = ProbePlusDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ProbePlusConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/probe_plus/config_flow.py b/homeassistant/components/probe_plus/config_flow.py new file mode 100644 index 00000000000..1e9a858e9fc --- /dev/null +++ b/homeassistant/components/probe_plus/config_flow.py @@ -0,0 +1,125 @@ +"""Config flow for probe_plus integration.""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class Discovery: + """Represents a discovered Bluetooth device. + + Attributes: + title: The name or title of the discovered device. + discovery_info: Information about the discovered device. + + """ + + title: str + discovery_info: BluetoothServiceInfo + + +def title(discovery_info: BluetoothServiceInfo) -> str: + """Return a title for the discovered device.""" + return f"{discovery_info.name} {discovery_info.address}" + + +class ProbeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BT Probe.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_devices: dict[str, Discovery] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered BT device: %s", discovery_info) + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = {"name": title(discovery_info)} + self._discovered_devices[discovery_info.address] = Discovery( + title(discovery_info), discovery_info + ) + + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the bluetooth confirmation step.""" + if user_input is not None: + assert self.unique_id + self._abort_if_unique_id_configured() + discovery = self._discovered_devices[self.unique_id] + return self.async_create_entry( + title=discovery.title, + data={ + CONF_ADDRESS: discovery.discovery_info.address, + }, + ) + self._set_confirm_only() + assert self.unique_id + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={ + "name": title(self._discovered_devices[self.unique_id].discovery_info) + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + discovery = self._discovered_devices[address] + return self.async_create_entry( + title=discovery.title, + data=user_input, + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + + self._discovered_devices[address] = Discovery( + title(discovery_info), discovery_info + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + titles = { + address: discovery.title + for (address, discovery) in self._discovered_devices.items() + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In(titles), + } + ), + ) diff --git a/homeassistant/components/probe_plus/const.py b/homeassistant/components/probe_plus/const.py new file mode 100644 index 00000000000..d0e2a7d6992 --- /dev/null +++ b/homeassistant/components/probe_plus/const.py @@ -0,0 +1,3 @@ +"""Constants for the Probe Plus integration.""" + +DOMAIN = "probe_plus" diff --git a/homeassistant/components/probe_plus/coordinator.py b/homeassistant/components/probe_plus/coordinator.py new file mode 100644 index 00000000000..b712e3fc84b --- /dev/null +++ b/homeassistant/components/probe_plus/coordinator.py @@ -0,0 +1,68 @@ +"""Coordinator for the probe_plus integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pyprobeplus import ProbePlusDevice +from pyprobeplus.exceptions import ProbePlusDeviceNotFound, ProbePlusError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +type ProbePlusConfigEntry = ConfigEntry[ProbePlusDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=15) + + +class ProbePlusDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Coordinator to manage data updates for a probe device. + + This class handles the communication with Probe Plus devices. + + Data is updated by the device itself. + """ + + config_entry: ProbePlusConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ProbePlusConfigEntry) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name="ProbePlusDataUpdateCoordinator", + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + + self.device: ProbePlusDevice = ProbePlusDevice( + address_or_ble_device=entry.data[CONF_ADDRESS], + name=entry.title, + notify_callback=self.async_update_listeners, + ) + + async def _async_update_data(self) -> None: + """Connect to the Probe Plus device on a set interval. + + This method is called periodically to reconnect to the device + Data updates are handled by the device itself. + """ + # Already connected, no need to update any data as the device streams this. + if self.device.connected: + return + + # Probe is not connected, try to connect + try: + await self.device.connect() + except (ProbePlusError, ProbePlusDeviceNotFound, TimeoutError) as e: + _LOGGER.debug( + "Could not connect to scale: %s, Error: %s", + self.config_entry.data[CONF_ADDRESS], + e, + ) + self.device.device_disconnected_handler(notify=False) + return diff --git a/homeassistant/components/probe_plus/entity.py b/homeassistant/components/probe_plus/entity.py new file mode 100644 index 00000000000..c2c53f5bca4 --- /dev/null +++ b/homeassistant/components/probe_plus/entity.py @@ -0,0 +1,54 @@ +"""Probe Plus base entity type.""" + +from dataclasses import dataclass + +from pyprobeplus import ProbePlusDevice + +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ProbePlusDataUpdateCoordinator + + +@dataclass +class ProbePlusEntity(CoordinatorEntity[ProbePlusDataUpdateCoordinator]): + """Base class for Probe Plus entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ProbePlusDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + + # Set the unique ID for the entity + self._attr_unique_id = ( + f"{format_mac(coordinator.device.mac)}_{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_mac(coordinator.device.mac))}, + name=coordinator.device.name, + manufacturer="Probe Plus", + suggested_area="Kitchen", + connections={(CONNECTION_BLUETOOTH, coordinator.device.mac)}, + ) + + @property + def available(self) -> bool: + """Return True if the entity is available.""" + return super().available and self.coordinator.device.connected + + @property + def device(self) -> ProbePlusDevice: + """Return the device associated with this entity.""" + return self.coordinator.device diff --git a/homeassistant/components/probe_plus/icons.json b/homeassistant/components/probe_plus/icons.json new file mode 100644 index 00000000000..d76bbd39873 --- /dev/null +++ b/homeassistant/components/probe_plus/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "probe_temperature": { + "default": "mdi:thermometer-bluetooth" + } + } + } +} diff --git a/homeassistant/components/probe_plus/manifest.json b/homeassistant/components/probe_plus/manifest.json new file mode 100644 index 00000000000..e7db39b8ae4 --- /dev/null +++ b/homeassistant/components/probe_plus/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "probe_plus", + "name": "Probe Plus", + "bluetooth": [ + { + "connectable": true, + "manufacturer_id": 36606, + "local_name": "FM2*" + } + ], + "codeowners": ["@pantherale0"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/probe_plus", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["pyprobeplus==1.0.1"] +} diff --git a/homeassistant/components/probe_plus/quality_scale.yaml b/homeassistant/components/probe_plus/quality_scale.yaml new file mode 100644 index 00000000000..d06d36d41de --- /dev/null +++ b/homeassistant/components/probe_plus/quality_scale.yaml @@ -0,0 +1,100 @@ +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: + status: exempt + comment: | + Device is expected to be offline most of the time, but needs to connect quickly once available. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: | + Handled by coordinator. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + No authentication required. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No IP discovery. + discovery: + status: done + comment: | + The integration uses Bluetooth discovery to find devices. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: exempt + comment: | + No custom exceptions are defined. + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + No reconfiguration flow is needed as the only thing that could be changed is the MAC, which is already hardcoded on the device itself. + repair-issues: + status: exempt + comment: | + No repair issues. + stale-devices: + status: exempt + comment: | + The device itself is the integration. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + No web session is used. + strict-typing: todo diff --git a/homeassistant/components/probe_plus/sensor.py b/homeassistant/components/probe_plus/sensor.py new file mode 100644 index 00000000000..9834a1433a4 --- /dev/null +++ b/homeassistant/components/probe_plus/sensor.py @@ -0,0 +1,106 @@ +"""Support for Probe Plus BLE sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricPotential, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ProbePlusConfigEntry, ProbePlusDevice +from .entity import ProbePlusEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class ProbePlusSensorEntityDescription(SensorEntityDescription): + """Description for Probe Plus sensor entities.""" + + value_fn: Callable[[ProbePlusDevice], int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[ProbePlusSensorEntityDescription, ...] = ( + ProbePlusSensorEntityDescription( + key="probe_temperature", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda device: device.device_state.probe_temperature, + device_class=SensorDeviceClass.TEMPERATURE, + ), + ProbePlusSensorEntityDescription( + key="probe_battery", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.device_state.probe_battery, + device_class=SensorDeviceClass.BATTERY, + ), + ProbePlusSensorEntityDescription( + key="relay_battery", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.device_state.relay_battery, + device_class=SensorDeviceClass.BATTERY, + ), + ProbePlusSensorEntityDescription( + key="probe_rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.device_state.probe_rssi, + entity_registry_enabled_default=False, + ), + ProbePlusSensorEntityDescription( + key="relay_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLTAGE, + value_fn=lambda device: device.device_state.relay_voltage, + entity_registry_enabled_default=False, + ), + ProbePlusSensorEntityDescription( + key="probe_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLTAGE, + value_fn=lambda device: device.device_state.probe_voltage, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ProbePlusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Probe Plus sensors.""" + coordinator = entry.runtime_data + async_add_entities(ProbeSensor(coordinator, desc) for desc in SENSOR_DESCRIPTIONS) + + +class ProbeSensor(ProbePlusEntity, RestoreSensor): + """Representation of a Probe Plus sensor.""" + + entity_description: ProbePlusSensorEntityDescription + + @property + def native_value(self) -> int | float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/probe_plus/strings.json b/homeassistant/components/probe_plus/strings.json new file mode 100644 index 00000000000..45fd4be39ce --- /dev/null +++ b/homeassistant/components/probe_plus/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "flow_title": "{name}", + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "error": { + "device_not_found": "Device could not be found.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Select BLE probe you want to set up" + } + } + } + }, + "entity": { + "sensor": { + "probe_battery": { + "name": "Probe battery" + }, + "probe_temperature": { + "name": "Probe temperature" + }, + "probe_rssi": { + "name": "Probe RSSI" + }, + "probe_voltage": { + "name": "Probe voltage" + }, + "relay_battery": { + "name": "Relay battery" + }, + "relay_voltage": { + "name": "Relay voltage" + } + } + } +} diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 04dc6d76a5e..de14dc30d54 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -256,7 +256,7 @@ async def async_setup_entry( # noqa: C901 """Log all scheduled in the event loop.""" with _increase_repr_limit(): handle: asyncio.Handle - for handle in getattr(hass.loop, "_scheduled"): + for handle in getattr(hass.loop, "_scheduled"): # noqa: B009 if not handle.cancelled(): _LOGGER.critical("Scheduled: %s", handle) diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 2ccf086071a..ddde4620871 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -1,11 +1,14 @@ """Support for PlayStation 4 consoles.""" +from __future__ import annotations + +from dataclasses import dataclass import logging import os +from typing import TYPE_CHECKING -from pyps4_2ndscreen.ddp import async_create_ddp_endpoint +from pyps4_2ndscreen.ddp import DDPProtocol, async_create_ddp_endpoint from pyps4_2ndscreen.media_art import COUNTRIES -import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.components.media_player import ( @@ -14,15 +17,8 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_COMMAND, - ATTR_ENTITY_ID, - ATTR_LOCKED, - CONF_REGION, - CONF_TOKEN, - Platform, -) -from homeassistant.core import HomeAssistant, ServiceCall, split_entity_id +from homeassistant.const import ATTR_LOCKED, CONF_REGION, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -32,48 +28,37 @@ from homeassistant.util import location as location_util from homeassistant.util.json import JsonObjectType, load_json_object from .config_flow import PlayStation4FlowHandler # noqa: F401 -from .const import ( - ATTR_MEDIA_IMAGE_URL, - COMMANDS, - COUNTRYCODE_NAMES, - DOMAIN, - GAMES_FILE, - PS4_DATA, -) +from .const import ATTR_MEDIA_IMAGE_URL, COUNTRYCODE_NAMES, DOMAIN, GAMES_FILE, PS4_DATA +from .services import register_services + +if TYPE_CHECKING: + from .media_player import PS4Device _LOGGER = logging.getLogger(__name__) -SERVICE_COMMAND = "send_command" - -PS4_COMMAND_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_COMMAND): vol.In(list(COMMANDS)), - } -) PLATFORMS = [Platform.MEDIA_PLAYER] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +@dataclass class PS4Data: """Init Data Class.""" - def __init__(self): - """Init Class.""" - self.devices = [] - self.protocol = None + devices: list[PS4Device] + protocol: DDPProtocol async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the PS4 Component.""" - hass.data[PS4_DATA] = PS4Data() - transport, protocol = await async_create_ddp_endpoint() - hass.data[PS4_DATA].protocol = protocol + hass.data[PS4_DATA] = PS4Data( + devices=[], + protocol=protocol, + ) _LOGGER.debug("PS4 DDP endpoint created: %s, %s", transport, protocol) - service_handle(hass) + register_services(hass) return True @@ -216,19 +201,3 @@ def _reformat_data(hass: HomeAssistant, games: dict, unique_id: str) -> dict: if data_reformatted: save_games(hass, games, unique_id) return games - - -def service_handle(hass: HomeAssistant): - """Handle for services.""" - - async def async_service_command(call: ServiceCall) -> None: - """Service for sending commands.""" - entity_ids = call.data[ATTR_ENTITY_ID] - command = call.data[ATTR_COMMAND] - for device in hass.data[PS4_DATA].devices: - if device.entity_id in entity_ids: - await device.async_send_command(command) - - hass.services.async_register( - DOMAIN, SERVICE_COMMAND, async_service_command, schema=PS4_COMMAND_SCHEMA - ) diff --git a/homeassistant/components/ps4/const.py b/homeassistant/components/ps4/const.py index bd1144c4d98..f552388fe1d 100644 --- a/homeassistant/components/ps4/const.py +++ b/homeassistant/components/ps4/const.py @@ -1,5 +1,14 @@ """Constants for PlayStation 4.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from . import PS4Data + ATTR_MEDIA_IMAGE_URL = "media_image_url" CONFIG_ENTRY_VERSION = 3 DEFAULT_NAME = "PlayStation 4" @@ -7,7 +16,7 @@ DEFAULT_REGION = "United States" DEFAULT_ALIAS = "Home-Assistant" DOMAIN = "ps4" GAMES_FILE = ".ps4-games.{}.json" -PS4_DATA = "ps4_data" +PS4_DATA: HassKey[PS4Data] = HassKey(DOMAIN) COMMANDS = ("up", "down", "right", "left", "enter", "back", "option", "ps", "ps_hold") diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 4de7cbeb463..aaec7cdf105 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -34,7 +34,7 @@ from . import format_unique_id, load_games, save_games from .const import ( ATTR_MEDIA_IMAGE_URL, DEFAULT_ALIAS, - DOMAIN as PS4_DOMAIN, + DOMAIN, PS4_DATA, REGIONS as deprecated_regions, ) @@ -366,7 +366,7 @@ class PS4Device(MediaPlayerEntity): _sw_version = _sw_version[1:4] sw_version = f"{_sw_version[0]}.{_sw_version[1:]}" self._attr_device_info = DeviceInfo( - identifiers={(PS4_DOMAIN, status["host-id"])}, + identifiers={(DOMAIN, status["host-id"])}, manufacturer="Sony Interactive Entertainment Inc.", model="PlayStation 4", name=status["host-name"], diff --git a/homeassistant/components/ps4/services.py b/homeassistant/components/ps4/services.py new file mode 100644 index 00000000000..7da3cb0ae93 --- /dev/null +++ b/homeassistant/components/ps4/services.py @@ -0,0 +1,37 @@ +"""Support for PlayStation 4 consoles.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .const import COMMANDS, DOMAIN, PS4_DATA + +SERVICE_COMMAND = "send_command" + +PS4_COMMAND_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_COMMAND): vol.In(list(COMMANDS)), + } +) + + +async def async_service_command(call: ServiceCall) -> None: + """Service for sending commands.""" + entity_ids = call.data[ATTR_ENTITY_ID] + command = call.data[ATTR_COMMAND] + for device in call.hass.data[PS4_DATA].devices: + if device.entity_id in entity_ids: + await device.async_send_command(command) + + +def register_services(hass: HomeAssistant) -> None: + """Handle for services.""" + + hass.services.async_register( + DOMAIN, SERVICE_COMMAND, async_service_command, schema=PS4_COMMAND_SCHEMA + ) diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 603fe89d542..7c1d37712bb 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -61,7 +61,7 @@ async def async_setup_platform( if PUSH_CAMERA_DATA not in hass.data: hass.data[PUSH_CAMERA_DATA] = {} - webhook_id = config.get(CONF_WEBHOOK_ID) + webhook_id = config[CONF_WEBHOOK_ID] cameras = [ PushCamera( @@ -101,16 +101,27 @@ async def handle_webhook( class PushCamera(Camera): """The representation of a Push camera.""" - def __init__(self, hass, name, buffer_size, timeout, image_field, webhook_id): + _attr_motion_detection_enabled = False + name: str + + def __init__( + self, + hass: HomeAssistant, + name: str, + buffer_size: int, + timeout: timedelta, + image_field: str, + webhook_id: str, + ) -> None: """Initialize push camera component.""" super().__init__() - self._name = name + self._attr_name = name self._last_trip = None self._filename = None self._expired_listener = None self._timeout = timeout - self.queue = deque([], buffer_size) - self._current_image = None + self.queue: deque[bytes] = deque([], buffer_size) + self._current_image: bytes | None = None self._image_field = image_field self.webhook_id = webhook_id self.webhook_url = webhook.async_generate_url(hass, webhook_id) @@ -171,16 +182,6 @@ class PushCamera(Camera): return self._current_image - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def motion_detection_enabled(self): - """Camera Motion Detection Status.""" - return False - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py index 57d97c046b7..c6f234a14b7 100644 --- a/homeassistant/components/qbus/climate.py +++ b/homeassistant/components/qbus/climate.py @@ -57,6 +57,7 @@ async def async_setup_entry( class QbusClimate(QbusEntity, ClimateEntity): """Representation of a Qbus climate entity.""" + _attr_name = None _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index 767a41f48cc..e679c4b9927 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -8,6 +8,7 @@ DOMAIN: Final = "qbus" PLATFORMS: list[Platform] = [ Platform.CLIMATE, Platform.LIGHT, + Platform.SCENE, Platform.SWITCH, ] diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py index dd57a98787b..42e226c8e6a 100644 --- a/homeassistant/components/qbus/coordinator.py +++ b/homeassistant/components/qbus/coordinator.py @@ -105,6 +105,7 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, self._controller.mac)}, identifiers={(DOMAIN, format_mac(self._controller.mac))}, manufacturer=MANUFACTURER, model="CTD3.x", diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index 4ab1913c4dc..70d469f9c93 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -54,34 +54,39 @@ def format_ref_id(ref_id: str) -> str | None: return None +def create_main_device_identifier(mqtt_output: QbusMqttOutput) -> tuple[str, str]: + """Create the identifier referring to the main device this output belongs to.""" + return (DOMAIN, format_mac(mqtt_output.device.mac)) + + class QbusEntity(Entity, ABC): """Representation of a Qbus entity.""" _attr_has_entity_name = True - _attr_name = None _attr_should_poll = False def __init__(self, mqtt_output: QbusMqttOutput) -> None: """Initialize the Qbus entity.""" + self._mqtt_output = mqtt_output + self._topic_factory = QbusMqttTopicFactory() self._message_factory = QbusMqttMessageFactory() + self._state_topic = self._topic_factory.get_output_state_topic( + mqtt_output.device.id, mqtt_output.id + ) ref_id = format_ref_id(mqtt_output.ref_id) self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" + # Create linked device self._attr_device_info = DeviceInfo( name=mqtt_output.name.title(), manufacturer=MANUFACTURER, identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, suggested_area=mqtt_output.location.title(), - via_device=(DOMAIN, format_mac(mqtt_output.device.mac)), - ) - - self._mqtt_output = mqtt_output - self._state_topic = self._topic_factory.get_output_state_topic( - mqtt_output.device.id, mqtt_output.id + via_device=create_main_device_identifier(mqtt_output), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 3d2c763b8e3..654aab80ac7 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -43,6 +43,7 @@ async def async_setup_entry( class QbusLight(QbusEntity, LightEntity): """Representation of a Qbus light entity.""" + _attr_name = None _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_color_mode = ColorMode.BRIGHTNESS diff --git a/homeassistant/components/qbus/scene.py b/homeassistant/components/qbus/scene.py new file mode 100644 index 00000000000..9a9a1e2df83 --- /dev/null +++ b/homeassistant/components/qbus/scene.py @@ -0,0 +1,66 @@ +"""Support for Qbus scene.""" + +from typing import Any + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttState, StateAction, StateType + +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.components.scene import Scene +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, add_new_outputs, create_main_device_identifier + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up scene entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "scene", + QbusScene, + async_add_entities, + ) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusScene(QbusEntity, Scene): + """Representation of a Qbus scene entity.""" + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize scene entity.""" + + super().__init__(mqtt_output) + + # Add to main controller device + self._attr_device_info = DeviceInfo( + identifiers={create_main_device_identifier(mqtt_output)} + ) + self._attr_name = mqtt_output.name.title() + + async def async_activate(self, **kwargs: Any) -> None: + """Activate scene.""" + state = QbusMqttState( + id=self._mqtt_output.id, type=StateType.ACTION, action=StateAction.ACTIVE + ) + await self._async_publish_output_state(state) + + async def _state_received(self, msg: ReceiveMessage) -> None: + # Nothing to do + pass diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index e1feccf4450..c0e2b112bc5 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -42,6 +42,7 @@ async def async_setup_entry( class QbusSwitch(QbusEntity, SwitchEntity): """Representation of a Qbus switch entity.""" + _attr_name = None _attr_device_class = SwitchDeviceClass.SWITCH def __init__(self, mqtt_output: QbusMqttOutput) -> None: diff --git a/homeassistant/components/qrcode/image_processing.py b/homeassistant/components/qrcode/image_processing.py index bec0cea8c2f..f81969b63b6 100644 --- a/homeassistant/components/qrcode/image_processing.py +++ b/homeassistant/components/qrcode/image_processing.py @@ -21,48 +21,33 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the QR code image processing platform.""" + source: list[dict[str, str]] = config[CONF_SOURCE] add_entities( - QrEntity(camera[CONF_ENTITY_ID], camera.get(CONF_NAME)) - for camera in config[CONF_SOURCE] + QrEntity(camera[CONF_ENTITY_ID], camera.get(CONF_NAME)) for camera in source ) class QrEntity(ImageProcessingEntity): """A QR image processing entity.""" - def __init__(self, camera_entity, name): + def __init__(self, camera_entity: str, name: str | None) -> None: """Initialize QR image processing entity.""" super().__init__() - self._camera = camera_entity + self._attr_camera_entity = camera_entity if name: - self._name = name + self._attr_name = name else: - self._name = f"QR {split_entity_id(camera_entity)[1]}" - self._state = None + self._attr_name = f"QR {split_entity_id(camera_entity)[1]}" + self._attr_state = None - @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera - - @property - def state(self): - """Return the state of the entity.""" - return self._state - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - def process_image(self, image): + def process_image(self, image: bytes) -> None: """Process image.""" stream = io.BytesIO(image) img = Image.open(stream) barcodes = pyzbar.decode(img) if barcodes: - self._state = barcodes[0].data.decode("utf-8") + self._attr_state = barcodes[0].data.decode("utf-8") else: - self._state = None + self._attr_state = None diff --git a/homeassistant/components/quantum_gateway/const.py b/homeassistant/components/quantum_gateway/const.py new file mode 100644 index 00000000000..6e8bae10065 --- /dev/null +++ b/homeassistant/components/quantum_gateway/const.py @@ -0,0 +1,7 @@ +"""Constants for Quantum Gateway.""" + +import logging + +LOGGER = logging.getLogger(__package__) + +DEFAULT_HOST = "myfiosgateway.com" diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index 6491dca2e2c..c3eddc37f22 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -2,8 +2,6 @@ from __future__ import annotations -import logging - from quantum_gateway import QuantumGatewayScanner from requests.exceptions import RequestException import voluptuous as vol @@ -18,9 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - -DEFAULT_HOST = "myfiosgateway.com" +from .const import DEFAULT_HOST, LOGGER PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { @@ -43,13 +39,13 @@ def get_scanner( class QuantumGatewayDeviceScanner(DeviceScanner): """Class which queries a Quantum Gateway.""" - def __init__(self, config): + def __init__(self, config) -> None: """Initialize the scanner.""" self.host = config[CONF_HOST] self.password = config[CONF_PASSWORD] self.use_https = config[CONF_SSL] - _LOGGER.debug("Initializing") + LOGGER.debug("Initializing") try: self.quantum = QuantumGatewayScanner( @@ -58,10 +54,10 @@ class QuantumGatewayDeviceScanner(DeviceScanner): self.success_init = self.quantum.success_init except RequestException: self.success_init = False - _LOGGER.error("Unable to connect to gateway. Check host") + LOGGER.error("Unable to connect to gateway. Check host") if not self.success_init: - _LOGGER.error("Unable to login to gateway. Check password and host") + LOGGER.error("Unable to login to gateway. Check password and host") def scan_devices(self): """Scan for new devices and return a list of found MACs.""" @@ -69,7 +65,7 @@ class QuantumGatewayDeviceScanner(DeviceScanner): try: connected_devices = self.quantum.scan_devices() except RequestException: - _LOGGER.error("Unable to scan devices. Check connection to router") + LOGGER.error("Unable to scan devices. Check connection to router") return connected_devices def get_device_name(self, device): diff --git a/homeassistant/components/qwikswitch/binary_sensor.py b/homeassistant/components/qwikswitch/binary_sensor.py index 195433ebc17..bbe8d309e50 100644 --- a/homeassistant/components/qwikswitch/binary_sensor.py +++ b/homeassistant/components/qwikswitch/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH +from . import DOMAIN from .entity import QSEntity _LOGGER = logging.getLogger(__name__) @@ -27,9 +27,9 @@ async def async_setup_platform( if discovery_info is None: return - qsusb = hass.data[QWIKSWITCH] + qsusb = hass.data[DOMAIN] _LOGGER.debug("Setup qwikswitch.binary_sensor %s, %s", qsusb, discovery_info) - devs = [QSBinarySensor(sensor) for sensor in discovery_info[QWIKSWITCH]] + devs = [QSBinarySensor(sensor) for sensor in discovery_info[DOMAIN]] add_entities(devs) diff --git a/homeassistant/components/qwikswitch/light.py b/homeassistant/components/qwikswitch/light.py index 073f7bb873a..0f91faeedc8 100644 --- a/homeassistant/components/qwikswitch/light.py +++ b/homeassistant/components/qwikswitch/light.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH +from . import DOMAIN from .entity import QSToggleEntity @@ -21,8 +21,8 @@ async def async_setup_platform( if discovery_info is None: return - qsusb = hass.data[QWIKSWITCH] - devs = [QSLight(qsid, qsusb) for qsid in discovery_info[QWIKSWITCH]] + qsusb = hass.data[DOMAIN] + devs = [QSLight(qsid, qsusb) for qsid in discovery_info[DOMAIN]] add_entities(devs) diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index 64b95fb17f6..e87fae83464 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH +from . import DOMAIN from .entity import QSEntity _LOGGER = logging.getLogger(__name__) @@ -28,9 +28,9 @@ async def async_setup_platform( if discovery_info is None: return - qsusb = hass.data[QWIKSWITCH] + qsusb = hass.data[DOMAIN] _LOGGER.debug("Setup qwikswitch.sensor %s, %s", qsusb, discovery_info) - devs = [QSSensor(sensor) for sensor in discovery_info[QWIKSWITCH]] + devs = [QSSensor(sensor) for sensor in discovery_info[DOMAIN]] add_entities(devs) diff --git a/homeassistant/components/qwikswitch/switch.py b/homeassistant/components/qwikswitch/switch.py index ec47b4d99f2..6131d9e595c 100644 --- a/homeassistant/components/qwikswitch/switch.py +++ b/homeassistant/components/qwikswitch/switch.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH +from . import DOMAIN from .entity import QSToggleEntity @@ -21,8 +21,8 @@ async def async_setup_platform( if discovery_info is None: return - qsusb = hass.data[QWIKSWITCH] - devs = [QSSwitch(qsid, qsusb) for qsid in discovery_info[QWIKSWITCH]] + qsusb = hass.data[DOMAIN] + devs = [QSSwitch(qsid, qsusb) for qsid in discovery_info[DOMAIN]] add_entities(devs) diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index d6cdd2701b6..ab0886096cc 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -7,13 +7,12 @@ from rachiopy import Rachio from requests.exceptions import ConnectTimeout from homeassistant.components import cloud -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN -from .device import RachioPerson +from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS +from .device import RachioConfigEntry, RachioPerson from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, async_register_webhook, @@ -25,21 +24,20 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SWITCH] -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): async_unregister_webhook(hass, entry) - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> None: """Remove a rachio config entry.""" if CONF_CLOUDHOOK_URL in entry.data: await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> bool: """Set up the Rachio config entry.""" config = entry.data @@ -97,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await base.schedule_coordinator.async_config_entry_first_refresh() # Enable platform - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = person + entry.runtime_data = person async_register_webhook(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 3bf0f716c6d..dbe41de2c4c 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -1,28 +1,29 @@ """Integration with the Rachio Iro sprinkler system controller.""" -from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass import logging from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN as DOMAIN_RACHIO, - KEY_BATTERY_STATUS, + KEY_BATTERY, + KEY_DETECT_FLOW, KEY_DEVICE_ID, - KEY_LOW, + KEY_FLOW, + KEY_ONLINE, + KEY_RAIN_SENSOR, KEY_RAIN_SENSOR_TRIPPED, - KEY_REPLACE, - KEY_REPORTED_STATE, - KEY_STATE, KEY_STATUS, KEY_SUBTYPE, SIGNAL_RACHIO_CONTROLLER_UPDATE, @@ -30,7 +31,7 @@ from .const import ( STATUS_ONLINE, ) from .coordinator import RachioUpdateCoordinator -from .device import RachioPerson +from .device import RachioConfigEntry, RachioIro from .entity import RachioDevice, RachioHoseTimerEntity from .webhooks import ( SUBTYPE_COLD_REBOOT, @@ -43,9 +44,70 @@ from .webhooks import ( _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class RachioControllerBinarySensorDescription(BinarySensorEntityDescription): + """Describe a Rachio controller binary sensor.""" + + update_received: Callable[[str], bool | None] + is_on: Callable[[RachioIro], bool] + signal_string: str + + +CONTROLLER_BINARY_SENSOR_TYPES: tuple[RachioControllerBinarySensorDescription, ...] = ( + RachioControllerBinarySensorDescription( + key=KEY_ONLINE, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + signal_string=SIGNAL_RACHIO_CONTROLLER_UPDATE, + is_on=lambda controller: controller.init_data[KEY_STATUS] == STATUS_ONLINE, + update_received={ + SUBTYPE_ONLINE: True, + SUBTYPE_COLD_REBOOT: True, + SUBTYPE_OFFLINE: False, + }.get, + ), + RachioControllerBinarySensorDescription( + key=KEY_RAIN_SENSOR, + translation_key="rain", + device_class=BinarySensorDeviceClass.MOISTURE, + signal_string=SIGNAL_RACHIO_RAIN_SENSOR_UPDATE, + is_on=lambda controller: controller.init_data[KEY_RAIN_SENSOR_TRIPPED], + update_received={ + SUBTYPE_RAIN_SENSOR_DETECTION_ON: True, + SUBTYPE_RAIN_SENSOR_DETECTION_OFF: False, + }.get, + ), +) + + +@dataclass(frozen=True, kw_only=True) +class RachioHoseTimerBinarySensorDescription(BinarySensorEntityDescription): + """Describe a Rachio hose timer binary sensor.""" + + value_fn: Callable[[RachioHoseTimerEntity], bool] + exists_fn: Callable[[dict[str, Any]], bool] = lambda _: True + + +HOSE_TIMER_BINARY_SENSOR_TYPES: tuple[RachioHoseTimerBinarySensorDescription, ...] = ( + RachioHoseTimerBinarySensorDescription( + key=KEY_BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.BATTERY, + value_fn=lambda device: device.battery, + ), + RachioHoseTimerBinarySensorDescription( + key=KEY_FLOW, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="flow", + value_fn=lambda device: device.no_flow_detected, + exists_fn=lambda valve: valve[KEY_DETECT_FLOW], + ), +) + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RachioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio binary sensors.""" @@ -53,25 +115,42 @@ async def async_setup_entry( async_add_entities(entities) -def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: +def _create_entities( + hass: HomeAssistant, config_entry: RachioConfigEntry +) -> list[Entity]: entities: list[Entity] = [] - person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] - for controller in person.controllers: - entities.append(RachioControllerOnlineBinarySensor(controller)) - entities.append(RachioRainSensor(controller)) + person = config_entry.runtime_data entities.extend( - RachioHoseTimerBattery(valve, base_station.status_coordinator) + RachioControllerBinarySensor(controller, description) + for controller in person.controllers + for description in CONTROLLER_BINARY_SENSOR_TYPES + ) + entities.extend( + RachioHoseTimerBinarySensor(valve, base_station.status_coordinator, description) for base_station in person.base_stations for valve in base_station.status_coordinator.data.values() + for description in HOSE_TIMER_BINARY_SENSOR_TYPES + if description.exists_fn(valve) ) return entities class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): - """Represent a binary sensor that reflects a Rachio state.""" + """Represent a binary sensor that reflects a Rachio controller state.""" + entity_description: RachioControllerBinarySensorDescription _attr_has_entity_name = True + def __init__( + self, + controller: RachioIro, + description: RachioControllerBinarySensorDescription, + ) -> None: + """Initialize a controller binary sensor.""" + super().__init__(controller) + self.entity_description = description + self._attr_unique_id = f"{controller.controller_id}-{description.key}" + @callback def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" @@ -82,97 +161,49 @@ class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): # For this device self._async_handle_update(args, kwargs) - @abstractmethod - def _async_handle_update(self, *args, **kwargs) -> None: - """Handle an update to the state of this sensor.""" - - -class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): - """Represent a binary sensor that reflects if the controller is online.""" - - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - - @property - def unique_id(self) -> str: - """Return a unique id for this entity.""" - return f"{self._controller.controller_id}-online" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" - if args[0][0][KEY_SUBTYPE] in (SUBTYPE_ONLINE, SUBTYPE_COLD_REBOOT): - self._attr_is_on = True - elif args[0][0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: - self._attr_is_on = False + if ( + updated_state := self.entity_description.update_received( + args[0][0][KEY_SUBTYPE] + ) + ) is not None: + self._attr_is_on = updated_state self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._attr_is_on = self._controller.init_data[KEY_STATUS] == STATUS_ONLINE + self._attr_is_on = self.entity_description.is_on(self._controller) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_RACHIO_CONTROLLER_UPDATE, + self.entity_description.signal_string, self._async_handle_any_update, ) ) -class RachioRainSensor(RachioControllerBinarySensor): - """Represent a binary sensor that reflects the status of the rain sensor.""" +class RachioHoseTimerBinarySensor(RachioHoseTimerEntity, BinarySensorEntity): + """Represents a binary sensor for a smart hose timer.""" - _attr_device_class = BinarySensorDeviceClass.MOISTURE - _attr_translation_key = "rain" - - @property - def unique_id(self) -> str: - """Return a unique id for this entity.""" - return f"{self._controller.controller_id}-rain_sensor" - - @callback - def _async_handle_update(self, *args, **kwargs) -> None: - """Handle an update to the state of this sensor.""" - if args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_ON: - self._attr_is_on = True - elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_OFF: - self._attr_is_on = False - - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Subscribe to updates.""" - self._attr_is_on = self._controller.init_data[KEY_RAIN_SENSOR_TRIPPED] - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_RACHIO_RAIN_SENSOR_UPDATE, - self._async_handle_any_update, - ) - ) - - -class RachioHoseTimerBattery(RachioHoseTimerEntity, BinarySensorEntity): - """Represents a battery sensor for a smart hose timer.""" - - _attr_device_class = BinarySensorDeviceClass.BATTERY + entity_description: RachioHoseTimerBinarySensorDescription def __init__( - self, data: dict[str, Any], coordinator: RachioUpdateCoordinator + self, + data: dict[str, Any], + coordinator: RachioUpdateCoordinator, + description: RachioHoseTimerBinarySensorDescription, ) -> None: - """Initialize a smart hose timer battery sensor.""" + """Initialize a smart hose timer binary sensor.""" super().__init__(data, coordinator) - self._attr_unique_id = f"{self.id}-battery" + self.entity_description = description + self._attr_unique_id = f"{self.id}-{description.key}" + self._update_attr() @callback def _update_attr(self) -> None: """Handle updated coordinator data.""" - data = self.coordinator.data[self.id] - - self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] - self._attr_is_on = self._static_attrs[KEY_BATTERY_STATUS] in [ - KEY_LOW, - KEY_REPLACE, - ] + self._attr_is_on = self.entity_description.value_fn(self) diff --git a/homeassistant/components/rachio/calendar.py b/homeassistant/components/rachio/calendar.py index 91ad29fac9f..18b1b6a4d8f 100644 --- a/homeassistant/components/rachio/calendar.py +++ b/homeassistant/components/rachio/calendar.py @@ -9,7 +9,6 @@ from homeassistant.components.calendar import ( CalendarEntityFeature, CalendarEvent, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -17,7 +16,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( - DOMAIN as DOMAIN_RACHIO, KEY_ADDRESS, KEY_DURATION_SECONDS, KEY_ID, @@ -33,18 +31,18 @@ from .const import ( KEY_VALVE_NAME, ) from .coordinator import RachioScheduleUpdateCoordinator -from .device import RachioPerson +from .device import RachioConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RachioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for Rachio smart hose timer calendar.""" - person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] + person = config_entry.runtime_data async_add_entities( RachioCalendarEntity(base_station.schedule_coordinator, base_station) for base_station in person.base_stations diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index ad670fc3608..08a09f309f6 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -25,10 +25,12 @@ KEY_ID = "id" KEY_NAME = "name" KEY_MODEL = "model" KEY_ON = "on" +KEY_ONLINE = "online" KEY_DURATION = "totalDuration" KEY_DURATION_MINUTES = "duration" KEY_RAIN_DELAY = "rainDelayExpirationDate" KEY_RAIN_DELAY_END = "endTime" +KEY_RAIN_SENSOR = "rain_sensor" KEY_RAIN_SENSOR_TRIPPED = "rainSensorTripped" KEY_STATUS = "status" KEY_SUBTYPE = "subType" @@ -57,6 +59,8 @@ KEY_STATE = "state" KEY_CONNECTED = "connected" KEY_CURRENT_STATUS = "lastWateringAction" KEY_DETECT_FLOW = "detectFlow" +KEY_BATTERY = "battery" +KEY_FLOW = "flow" KEY_BATTERY_STATUS = "batteryStatus" KEY_LOW = "LOW" KEY_REPLACE = "REPLACE" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 179e5f5ec0d..a5dd3dba054 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -57,11 +57,13 @@ RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) +type RachioConfigEntry = ConfigEntry[RachioPerson] + class RachioPerson: """Represent a Rachio user.""" - def __init__(self, rachio: Rachio, config_entry: ConfigEntry) -> None: + def __init__(self, rachio: Rachio, config_entry: RachioConfigEntry) -> None: """Create an object from the provided API instance.""" # Use API token to get user ID self.rachio = rachio diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index 056abe9145b..10657a1f0e9 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -12,9 +12,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DEFAULT_NAME, DOMAIN, + KEY_BATTERY_STATUS, KEY_CONNECTED, + KEY_CURRENT_STATUS, + KEY_FLOW_DETECTED, KEY_ID, + KEY_LOW, KEY_NAME, + KEY_REPLACE, KEY_REPORTED_STATE, KEY_STATE, ) @@ -70,17 +75,29 @@ class RachioHoseTimerEntity(CoordinatorEntity[RachioUpdateCoordinator]): manufacturer=DEFAULT_NAME, configuration_url="https://app.rach.io", ) - self._update_attr() + + @property + def reported_state(self) -> dict[str, Any]: + """Return the reported state.""" + return self.coordinator.data[self.id][KEY_STATE][KEY_REPORTED_STATE] @property def available(self) -> bool: """Return if the entity is available.""" - return ( - super().available - and self.coordinator.data[self.id][KEY_STATE][KEY_REPORTED_STATE][ - KEY_CONNECTED - ] - ) + return super().available and self.reported_state[KEY_CONNECTED] + + @property + def battery(self) -> bool: + """Return the battery status.""" + return self.reported_state[KEY_BATTERY_STATUS] in [KEY_LOW, KEY_REPLACE] + + @property + def no_flow_detected(self) -> bool: + """Return true if valve is on and flow is not detected.""" + if status := self.reported_state.get(KEY_CURRENT_STATUS): + # Since this is a problem indicator we need the opposite of the API state + return not status.get(KEY_FLOW_DETECTED, True) + return False @abstractmethod def _update_attr(self) -> None: diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index d51a1d5f920..ea3c8911463 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -31,6 +31,9 @@ "binary_sensor": { "rain": { "name": "Rain" + }, + "flow": { + "name": "Flow" } }, "calendar": { diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 25cdeac62f7..bfd75ad7e8b 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -9,7 +9,6 @@ from typing import Any import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError @@ -23,7 +22,7 @@ from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_ti from .const import ( CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, - DOMAIN as DOMAIN_RACHIO, + DOMAIN, KEY_CURRENT_STATUS, KEY_CUSTOM_CROP, KEY_CUSTOM_SHADE, @@ -37,9 +36,7 @@ from .const import ( KEY_ON, KEY_RAIN_DELAY, KEY_RAIN_DELAY_END, - KEY_REPORTED_STATE, KEY_SCHEDULE_ID, - KEY_STATE, KEY_SUBTYPE, KEY_SUMMARY, KEY_TYPE, @@ -59,7 +56,7 @@ from .const import ( SLOPE_SLIGHT, SLOPE_STEEP, ) -from .device import RachioPerson +from .device import RachioConfigEntry from .entity import RachioDevice, RachioHoseTimerEntity from .webhooks import ( SUBTYPE_RAIN_DELAY_OFF, @@ -101,7 +98,7 @@ START_MULTIPLE_ZONES_SCHEMA = vol.Schema( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RachioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio switches.""" @@ -119,7 +116,7 @@ async def async_setup_entry( def start_multiple(service: ServiceCall) -> None: """Service to start multiple zones in sequence.""" zones_list = [] - person = hass.data[DOMAIN_RACHIO][config_entry.entry_id] + person = config_entry.runtime_data entity_id = service.data[ATTR_ENTITY_ID] duration = iter(service.data[ATTR_DURATION]) default_time = service.data[ATTR_DURATION][0] @@ -160,7 +157,7 @@ async def async_setup_entry( return hass.services.async_register( - DOMAIN_RACHIO, + DOMAIN, SERVICE_START_MULTIPLE_ZONES, start_multiple, schema=START_MULTIPLE_ZONES_SCHEMA, @@ -175,9 +172,11 @@ async def async_setup_entry( ) -def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: +def _create_entities( + hass: HomeAssistant, config_entry: RachioConfigEntry +) -> list[Entity]: entities: list[Entity] = [] - person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] + person = config_entry.runtime_data # Fetch the schedule once at startup # in order to avoid every zone doing it for controller in person.controllers: @@ -548,6 +547,7 @@ class RachioValve(RachioHoseTimerEntity, SwitchEntity): self._person = person self._base = base self._attr_unique_id = f"{self.id}-valve" + self._update_attr() def turn_on(self, **kwargs: Any) -> None: """Turn on this valve.""" @@ -575,7 +575,5 @@ class RachioValve(RachioHoseTimerEntity, SwitchEntity): @callback def _update_attr(self) -> None: """Handle updated coordinator data.""" - data = self.coordinator.data[self.id] - - self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] + self._static_attrs = self.reported_state self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py index 06cd0941dcc..a88df37cb7d 100644 --- a/homeassistant/components/rachio/webhooks.py +++ b/homeassistant/components/rachio/webhooks.py @@ -5,7 +5,6 @@ from __future__ import annotations from aiohttp import web from homeassistant.components import cloud, webhook -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, URL_API from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -21,7 +20,7 @@ from .const import ( SIGNAL_RACHIO_SCHEDULE_UPDATE, SIGNAL_RACHIO_ZONE_UPDATE, ) -from .device import RachioPerson +from .device import RachioConfigEntry # Device webhook values TYPE_CONTROLLER_STATUS = "DEVICE_STATUS" @@ -83,7 +82,7 @@ SIGNAL_MAP = { @callback -def async_register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: +def async_register_webhook(hass: HomeAssistant, entry: RachioConfigEntry) -> None: """Register a webhook.""" webhook_id: str = entry.data[CONF_WEBHOOK_ID] @@ -91,7 +90,7 @@ def async_register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: hass: HomeAssistant, webhook_id: str, request: web.Request ) -> web.Response: """Handle webhook calls from the server.""" - person: RachioPerson = hass.data[DOMAIN][entry.entry_id] + person = entry.runtime_data data = await request.json() try: @@ -114,14 +113,14 @@ def async_register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: @callback -def async_unregister_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: +def async_unregister_webhook(hass: HomeAssistant, entry: RachioConfigEntry) -> None: """Unregister a webhook.""" webhook_id: str = entry.data[CONF_WEBHOOK_ID] webhook.async_unregister(hass, webhook_id) async def async_get_or_create_registered_webhook_id_and_url( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RachioConfigEntry ) -> str: """Generate webhook url.""" config = entry.data.copy() diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index f188350138e..5ba30d5803b 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException import voluptuous as vol @@ -91,7 +92,7 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) """Return state attributes.""" return {"zone": self._zone} - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" try: await self.coordinator.controller.irrigate_zone( @@ -111,7 +112,7 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) self.async_write_ha_state() await self.coordinator.async_request_refresh() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" try: await self.coordinator.controller.stop_irrigation() diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index af0efb823b9..d57f2dc8eec 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -120,6 +120,7 @@ "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py index b9506c3688c..19a1b724c48 100644 --- a/homeassistant/components/raspyrfm/switch.py +++ b/homeassistant/components/raspyrfm/switch.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from raspyrfm_client import RaspyRFMClient from raspyrfm_client.device_implementations.controlunit.actions import Action from raspyrfm_client.device_implementations.controlunit.controlunit_constants import ( @@ -100,41 +102,27 @@ def setup_platform( class RaspyRFMSwitch(SwitchEntity): """Representation of a RaspyRFM switch.""" + _attr_assumed_state = True _attr_should_poll = False def __init__(self, raspyrfm_client, name: str, gateway, controlunit) -> None: """Initialize the switch.""" self._raspyrfm_client = raspyrfm_client - self._name = name + self._attr_name = name self._gateway = gateway self._controlunit = controlunit - self._state = None + self._attr_is_on = None - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def assumed_state(self): - """Return True when the current state cannot be queried.""" - return True - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state - - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self._raspyrfm_client.send(self._gateway, self._controlunit, Action.ON) - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" if Action.OFF in self._controlunit.get_supported_actions(): @@ -142,5 +130,5 @@ class RaspyRFMSwitch(SwitchEntity): else: self._raspyrfm_client.send(self._gateway, self._controlunit, Action.ON) - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() diff --git a/homeassistant/components/recorder/icons.json b/homeassistant/components/recorder/icons.json index 9e41637184a..87634bedcc8 100644 --- a/homeassistant/components/recorder/icons.json +++ b/homeassistant/components/recorder/icons.json @@ -11,6 +11,9 @@ }, "enable": { "service": "mdi:database" + }, + "get_statistics": { + "service": "mdi:chart-bar" } } } diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index cc74d7a2376..ba454c59bf3 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -8,7 +8,13 @@ from typing import cast import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.service import ( @@ -16,15 +22,18 @@ from homeassistant.helpers.service import ( async_register_admin_service, ) from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonArrayType, JsonObjectType from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN from .core import Recorder +from .statistics import statistics_during_period from .tasks import PurgeEntitiesTask, PurgeTask SERVICE_PURGE = "purge" SERVICE_PURGE_ENTITIES = "purge_entities" SERVICE_ENABLE = "enable" SERVICE_DISABLE = "disable" +SERVICE_GET_STATISTICS = "get_statistics" SERVICE_PURGE_SCHEMA = vol.Schema( { @@ -63,6 +72,20 @@ SERVICE_PURGE_ENTITIES_SCHEMA = vol.All( SERVICE_ENABLE_SCHEMA = vol.Schema({}) SERVICE_DISABLE_SCHEMA = vol.Schema({}) +SERVICE_GET_STATISTICS_SCHEMA = vol.Schema( + { + vol.Required("start_time"): cv.datetime, + vol.Optional("end_time"): cv.datetime, + vol.Required("statistic_ids"): vol.All(cv.ensure_list, [cv.string]), + vol.Required("period"): vol.In(["5minute", "hour", "day", "week", "month"]), + vol.Required("types"): vol.All( + cv.ensure_list, + [vol.In(["change", "last_reset", "max", "mean", "min", "state", "sum"])], + ), + vol.Optional("units"): vol.Schema({cv.string: cv.string}), + } +) + @callback def _async_register_purge_service(hass: HomeAssistant, instance: Recorder) -> None: @@ -135,6 +158,79 @@ def _async_register_disable_service(hass: HomeAssistant, instance: Recorder) -> ) +@callback +def _async_register_get_statistics_service( + hass: HomeAssistant, instance: Recorder +) -> None: + async def async_handle_get_statistics_service( + service: ServiceCall, + ) -> ServiceResponse: + """Handle calls to the get_statistics service.""" + start_time = dt_util.as_utc(service.data["start_time"]) + end_time = ( + dt_util.as_utc(service.data["end_time"]) + if "end_time" in service.data + else None + ) + + statistic_ids = service.data["statistic_ids"] + types = service.data["types"] + period = service.data["period"] + units = service.data.get("units") + + result = await instance.async_add_executor_job( + statistics_during_period, + hass, + start_time, + end_time, + statistic_ids, + period, + units, + types, + ) + + formatted_result: JsonObjectType = {} + for statistic_id, statistic_rows in result.items(): + formatted_statistic_rows: JsonArrayType = [] + + for row in statistic_rows: + formatted_row: JsonObjectType = { + "start": dt_util.utc_from_timestamp(row["start"]).isoformat(), + "end": dt_util.utc_from_timestamp(row["end"]).isoformat(), + } + if (last_reset := row.get("last_reset")) is not None: + formatted_row["last_reset"] = dt_util.utc_from_timestamp( + last_reset + ).isoformat() + if (state := row.get("state")) is not None: + formatted_row["state"] = state + if (sum_value := row.get("sum")) is not None: + formatted_row["sum"] = sum_value + if (min_value := row.get("min")) is not None: + formatted_row["min"] = min_value + if (max_value := row.get("max")) is not None: + formatted_row["max"] = max_value + if (mean := row.get("mean")) is not None: + formatted_row["mean"] = mean + if (change := row.get("change")) is not None: + formatted_row["change"] = change + + formatted_statistic_rows.append(formatted_row) + + formatted_result[statistic_id] = formatted_statistic_rows + + return {"statistics": formatted_result} + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_GET_STATISTICS, + async_handle_get_statistics_service, + schema=SERVICE_GET_STATISTICS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + @callback def async_register_services(hass: HomeAssistant, instance: Recorder) -> None: """Register recorder services.""" @@ -142,3 +238,4 @@ def async_register_services(hass: HomeAssistant, instance: Recorder) -> None: _async_register_purge_entities_service(hass, instance) _async_register_enable_service(hass, instance) _async_register_disable_service(hass, instance) + _async_register_get_statistics_service(hass, instance) diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index 7d7b926548c..65aa797d91b 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -48,3 +48,63 @@ purge_entities: disable: enable: + +get_statistics: + fields: + start_time: + required: true + example: "2025-01-01 00:00:00" + selector: + datetime: + + end_time: + required: false + example: "2025-01-02 00:00:00" + selector: + datetime: + + statistic_ids: + required: true + example: + - sensor.energy_consumption + - sensor.temperature + selector: + entity: + multiple: true + + period: + required: true + example: "hour" + selector: + select: + options: + - "5minute" + - "hour" + - "day" + - "week" + - "month" + + types: + required: true + example: + - "mean" + - "sum" + selector: + select: + options: + - "change" + - "last_reset" + - "max" + - "mean" + - "min" + - "state" + - "sum" + multiple: true + + units: + required: false + example: + energy: "kWh" + temperature: "°C" + selector: + object: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 80c0028ef7a..7f41358dddf 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -55,8 +55,10 @@ from homeassistant.util.unit_conversion import ( EnergyDistanceConverter, InformationConverter, MassConverter, + MassVolumeConcentrationConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -196,6 +198,9 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { BloodGlucoseConcentrationConverter.VALID_UNITS, BloodGlucoseConcentrationConverter, ), + **dict.fromkeys( + MassVolumeConcentrationConverter.VALID_UNITS, MassVolumeConcentrationConverter + ), **dict.fromkeys(ConductivityConverter.VALID_UNITS, ConductivityConverter), **dict.fromkeys(DataRateConverter.VALID_UNITS, DataRateConverter), **dict.fromkeys(DistanceConverter.VALID_UNITS, DistanceConverter), @@ -208,6 +213,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **dict.fromkeys(MassConverter.VALID_UNITS, MassConverter), **dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter), **dict.fromkeys(PressureConverter.VALID_UNITS, PressureConverter), + **dict.fromkeys(ReactiveEnergyConverter.VALID_UNITS, ReactiveEnergyConverter), **dict.fromkeys(SpeedConverter.VALID_UNITS, SpeedConverter), **dict.fromkeys(TemperatureConverter.VALID_UNITS, TemperatureConverter), **dict.fromkeys(UnitlessRatioConverter.VALID_UNITS, UnitlessRatioConverter), diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 0c8d47548bf..eb7e0c8b63d 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -66,6 +66,36 @@ "enable": { "name": "[%key:common::action::enable%]", "description": "Starts the recording of events and state changes." + }, + "get_statistics": { + "name": "Get statistics", + "description": "Retrieves statistics data for entities within a specific time period.", + "fields": { + "end_time": { + "name": "End time", + "description": "The end time for the statistics query. If omitted, returns all statistics from start time onward." + }, + "period": { + "name": "Period", + "description": "The time period to group statistics by." + }, + "start_time": { + "name": "Start time", + "description": "The start time for the statistics query." + }, + "statistic_ids": { + "name": "Statistic IDs", + "description": "The entity IDs or statistic IDs to return statistics for." + }, + "types": { + "name": "Types", + "description": "The types of statistics values to return." + }, + "units": { + "name": "Units", + "description": "Optional unit conversion mapping." + } + } } } } diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index f4058943971..d052631c5f6 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -28,8 +28,10 @@ from homeassistant.util.unit_conversion import ( EnergyDistanceConverter, InformationConverter, MassConverter, + MassVolumeConcentrationConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -61,6 +63,9 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("blood_glucose_concentration"): vol.In( BloodGlucoseConcentrationConverter.VALID_UNITS ), + vol.Optional("concentration"): vol.In( + MassVolumeConcentrationConverter.VALID_UNITS + ), vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS), vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), @@ -73,6 +78,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), + vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS), vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS), diff --git a/homeassistant/components/rehlko/__init__.py b/homeassistant/components/rehlko/__init__.py index bda2704a206..d07289d256c 100644 --- a/homeassistant/components/rehlko/__init__.py +++ b/homeassistant/components/rehlko/__init__.py @@ -10,6 +10,7 @@ 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 homeassistant.util import dt as dt_util from .const import ( CONF_REFRESH_TOKEN, @@ -21,14 +22,14 @@ from .const import ( ) from .coordinator import RehlkoConfigEntry, RehlkoRuntimeData, RehlkoUpdateCoordinator -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, 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) + rehlko = AioKem(session=websession, home_timezone=dt_util.get_default_time_zone()) # If requests take more than 20 seconds; timeout and let the setup retry. rehlko.set_timeout(20) diff --git a/homeassistant/components/rehlko/binary_sensor.py b/homeassistant/components/rehlko/binary_sensor.py new file mode 100644 index 00000000000..a2c0d694735 --- /dev/null +++ b/homeassistant/components/rehlko/binary_sensor.py @@ -0,0 +1,108 @@ +"""Binary sensor platform for Rehlko integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +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 .const import ( + DEVICE_DATA_DEVICES, + DEVICE_DATA_ID, + DEVICE_DATA_IS_CONNECTED, + GENERATOR_DATA_DEVICE, +) +from .coordinator import RehlkoConfigEntry +from .entity import RehlkoEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class RehlkoBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Rehlko binary sensor entities.""" + + on_value: str | bool = True + off_value: str | bool = False + document_key: str | None = None + connectivity_key: str | None = DEVICE_DATA_IS_CONNECTED + + +BINARY_SENSORS: tuple[RehlkoBinarySensorEntityDescription, ...] = ( + RehlkoBinarySensorEntityDescription( + key=DEVICE_DATA_IS_CONNECTED, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + document_key=GENERATOR_DATA_DEVICE, + # Entity is available when the device is disconnected + connectivity_key=None, + ), + RehlkoBinarySensorEntityDescription( + key="switchState", + translation_key="auto_run", + on_value="Auto", + off_value="Off", + ), + RehlkoBinarySensorEntityDescription( + key="engineOilPressureOk", + translation_key="oil_pressure", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + on_value=False, + off_value=True, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RehlkoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the binary sensor platform.""" + homes = config_entry.runtime_data.homes + coordinators = config_entry.runtime_data.coordinators + async_add_entities( + RehlkoBinarySensorEntity( + coordinators[device_data[DEVICE_DATA_ID]], + device_data[DEVICE_DATA_ID], + device_data, + sensor_description, + document_key=sensor_description.document_key, + connectivity_key=sensor_description.connectivity_key, + ) + for home_data in homes + for device_data in home_data[DEVICE_DATA_DEVICES] + for sensor_description in BINARY_SENSORS + ) + + +class RehlkoBinarySensorEntity(RehlkoEntity, BinarySensorEntity): + """Representation of a Binary Sensor.""" + + entity_description: RehlkoBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + if self._rehlko_value == self.entity_description.on_value: + return True + if self._rehlko_value == self.entity_description.off_value: + return False + _LOGGER.warning( + "Unexpected value for %s: %s", + self.entity_description.key, + self._rehlko_value, + ) + return None diff --git a/homeassistant/components/rehlko/const.py b/homeassistant/components/rehlko/const.py index f63c0872d46..6dced0ccda6 100644 --- a/homeassistant/components/rehlko/const.py +++ b/homeassistant/components/rehlko/const.py @@ -18,6 +18,7 @@ DEVICE_DATA_IS_CONNECTED = "isConnected" KOHLER = "Kohler" GENERATOR_DATA_DEVICE = "device" +GENERATOR_DATA_EXERCISE = "exercise" CONNECTION_EXCEPTIONS = ( TimeoutError, diff --git a/homeassistant/components/rehlko/entity.py b/homeassistant/components/rehlko/entity.py index 94d384e1949..d1c25742f42 100644 --- a/homeassistant/components/rehlko/entity.py +++ b/homeassistant/components/rehlko/entity.py @@ -43,7 +43,8 @@ class RehlkoEntity(CoordinatorEntity[RehlkoUpdateCoordinator]): device_id: int, device_data: dict, description: EntityDescription, - use_device_key: bool = False, + document_key: str | None = None, + connectivity_key: str | None = DEVICE_DATA_IS_CONNECTED, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -61,7 +62,8 @@ class RehlkoEntity(CoordinatorEntity[RehlkoUpdateCoordinator]): manufacturer=KOHLER, connections=_get_device_connections(device_data[DEVICE_DATA_MAC_ADDRESS]), ) - self._use_device_key = use_device_key + self._document_key = document_key + self._connectivity_key = connectivity_key @property def _device_data(self) -> dict[str, Any]: @@ -71,11 +73,15 @@ class RehlkoEntity(CoordinatorEntity[RehlkoUpdateCoordinator]): @property def _rehlko_value(self) -> str: """Return the sensor value.""" - if self._use_device_key: - return self._device_data[self.entity_description.key] + if self._document_key: + return self.coordinator.data[self._document_key][ + 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] + return super().available and ( + not self._connectivity_key or self._device_data[self._connectivity_key] + ) diff --git a/homeassistant/components/rehlko/manifest.json b/homeassistant/components/rehlko/manifest.json index 6b2f6190883..24c9608e661 100644 --- a/homeassistant/components/rehlko/manifest.json +++ b/homeassistant/components/rehlko/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_polling", "loggers": ["aiokem"], "quality_scale": "silver", - "requirements": ["aiokem==0.5.10"] + "requirements": ["aiokem==0.5.12"] } diff --git a/homeassistant/components/rehlko/sensor.py b/homeassistant/components/rehlko/sensor.py index 9186f0e0c9f..6ff45b1a464 100644 --- a/homeassistant/components/rehlko/sensor.py +++ b/homeassistant/components/rehlko/sensor.py @@ -2,7 +2,9 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,7 +27,12 @@ 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 .const import ( + DEVICE_DATA_DEVICES, + DEVICE_DATA_ID, + GENERATOR_DATA_DEVICE, + GENERATOR_DATA_EXERCISE, +) from .coordinator import RehlkoConfigEntry from .entity import RehlkoEntity @@ -37,7 +44,8 @@ PARALLEL_UPDATES = 0 class RehlkoSensorEntityDescription(SensorEntityDescription): """Class describing Rehlko sensor entities.""" - use_device_key: bool = False + document_key: str | None = None + value_fn: Callable[[str], datetime | None] | None = None SENSORS: tuple[RehlkoSensorEntityDescription, ...] = ( @@ -116,7 +124,7 @@ SENSORS: tuple[RehlkoSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - use_device_key=True, + document_key=GENERATOR_DATA_DEVICE, ), RehlkoSensorEntityDescription( key="runtimeSinceLastMaintenanceHours", @@ -132,7 +140,7 @@ SENSORS: tuple[RehlkoSensorEntityDescription, ...] = ( translation_key="device_ip_address", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - use_device_key=True, + document_key=GENERATOR_DATA_DEVICE, ), RehlkoSensorEntityDescription( key="serverIpAddress", @@ -171,7 +179,7 @@ SENSORS: tuple[RehlkoSensorEntityDescription, ...] = ( RehlkoSensorEntityDescription( key="status", translation_key="generator_status", - use_device_key=True, + document_key=GENERATOR_DATA_DEVICE, ), RehlkoSensorEntityDescription( key="engineState", @@ -181,6 +189,44 @@ SENSORS: tuple[RehlkoSensorEntityDescription, ...] = ( key="powerSource", translation_key="power_source", ), + RehlkoSensorEntityDescription( + key="lastRanTimestamp", + translation_key="last_run", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=datetime.fromisoformat, + ), + RehlkoSensorEntityDescription( + key="lastMaintenanceTimestamp", + translation_key="last_maintainance", + device_class=SensorDeviceClass.TIMESTAMP, + document_key=GENERATOR_DATA_DEVICE, + value_fn=datetime.fromisoformat, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="nextMaintenanceTimestamp", + translation_key="next_maintainance", + device_class=SensorDeviceClass.TIMESTAMP, + document_key=GENERATOR_DATA_DEVICE, + value_fn=datetime.fromisoformat, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="lastStartTimestamp", + translation_key="last_exercise", + device_class=SensorDeviceClass.TIMESTAMP, + document_key=GENERATOR_DATA_EXERCISE, + value_fn=datetime.fromisoformat, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="nextStartTimestamp", + translation_key="next_exercise", + device_class=SensorDeviceClass.TIMESTAMP, + document_key=GENERATOR_DATA_EXERCISE, + value_fn=datetime.fromisoformat, + entity_registry_enabled_default=False, + ), ) @@ -199,7 +245,7 @@ async def async_setup_entry( device_data[DEVICE_DATA_ID], device_data, sensor_description, - sensor_description.use_device_key, + sensor_description.document_key, ) for home_data in homes for device_data in home_data[DEVICE_DATA_DEVICES] @@ -210,7 +256,11 @@ async def async_setup_entry( class RehlkoSensorEntity(RehlkoEntity, SensorEntity): """Representation of a Rehlko sensor.""" + entity_description: RehlkoSensorEntityDescription + @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the sensor state.""" + if self.entity_description.value_fn: + return self.entity_description.value_fn(self._rehlko_value) return self._rehlko_value diff --git a/homeassistant/components/rehlko/strings.json b/homeassistant/components/rehlko/strings.json index 6b842173558..bdf0e3de01c 100644 --- a/homeassistant/components/rehlko/strings.json +++ b/homeassistant/components/rehlko/strings.json @@ -31,6 +31,14 @@ } }, "entity": { + "binary_sensor": { + "auto_run": { + "name": "Auto run" + }, + "oil_pressure": { + "name": "Oil pressure" + } + }, "sensor": { "engine_speed": { "name": "Engine speed" @@ -91,6 +99,21 @@ }, "generator_status": { "name": "Generator status" + }, + "last_run": { + "name": "Last run" + }, + "last_maintainance": { + "name": "Last maintainance" + }, + "next_maintainance": { + "name": "Next maintainance" + }, + "next_exercise": { + "name": "Next exercise" + }, + "last_exercise": { + "name": "Last exercise" } } }, diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 9cf39b7ce45..7bdc5362ae7 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.2.4"] + "requirements": ["ical==10.0.0"] } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 57d41c20521..aa4ee36fc67 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta import logging +from time import time from typing import Any from reolink_aio.api import RETRY_ATTEMPTS @@ -28,7 +30,13 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import ( + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, + CONF_BC_PORT, + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services @@ -150,6 +158,10 @@ async def async_setup_entry( if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED: # Their are new cameras/chimes connected, reload to add them. + _LOGGER.debug( + "Reloading Reolink %s to add new device (capabilities)", + host.api.nvr_name, + ) hass.async_create_task( hass.config_entries.async_reload(config_entry.entry_id) ) @@ -216,6 +228,24 @@ async def async_setup_entry( hass.http.register_view(PlaybackProxyView(hass)) + await register_callbacks(host, device_coordinator, hass) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + config_entry.async_on_unload( + config_entry.add_update_listener(entry_update_listener) + ) + + return True + + +async def register_callbacks( + host: ReolinkHost, + device_coordinator: DataUpdateCoordinator[None], + hass: HomeAssistant, +) -> None: + """Register update callbacks.""" + async def refresh(*args: Any) -> None: """Request refresh of coordinator.""" await device_coordinator.async_request_refresh() @@ -229,17 +259,29 @@ async def async_setup_entry( host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh) host.privacy_mode = host.api.baichuan.privacy_mode() + def generate_async_camera_wake(channel: int) -> Callable[[], None]: + def async_camera_wake() -> None: + """Request update when a battery camera wakes up.""" + if ( + not host.api.sleeping(channel) + and time() - host.last_wake[channel] + > BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL + ): + hass.loop.create_task(device_coordinator.async_request_refresh()) + + return async_camera_wake + host.api.baichuan.register_callback( "privacy_mode_change", async_privacy_mode_change, 623 ) - - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - - config_entry.async_on_unload( - config_entry.add_update_listener(entry_update_listener) - ) - - return True + for channel in host.api.channels: + if host.api.supported(channel, "battery"): + host.api.baichuan.register_callback( + f"camera_{channel}_wake", + generate_async_camera_wake(channel), + 145, + channel, + ) async def entry_update_listener( @@ -258,6 +300,9 @@ async def async_unload_entry( await host.stop() host.api.baichuan.unregister_callback("privacy_mode_change") + for channel in host.api.channels: + if host.api.supported(channel, "battery"): + host.api.baichuan.unregister_callback(f"camera_{channel}_wake") if host.cancel_refresh_privacy_mode is not None: host.cancel_refresh_privacy_mode() diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 95c5f1982c3..2d08e42a6c8 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -115,6 +115,7 @@ BINARY_PUSH_SENSORS = ( translation_key="visitor", value=lambda api, ch: api.visitor_detected(ch), supported=lambda api, ch: api.is_doorbell(ch), + always_available=True, ), ReolinkBinarySensorEntityDescription( key="cry", diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 12ccd455be3..659169c3618 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -194,6 +194,13 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): ) raise AbortFlow("already_configured") + if existing_entry and existing_entry.data[CONF_HOST] != discovery_info.ip: + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', updating from old IP '%s'", + discovery_info.ip, + existing_entry.data[CONF_HOST], + ) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self.context["title_placeholders"] = { diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 026d1219881..bd9c4bb84a2 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -5,3 +5,9 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" CONF_BC_PORT = "baichuan_port" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" + +# Conserve battery by not waking the battery cameras each minute during normal update +# Most props are cached in the Home Hub and updated, but some are skipped +BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL = 3600 # seconds +BATTERY_WAKE_UPDATE_INTERVAL = 6 * BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL +BATTERY_ALL_WAKE_UPDATE_INTERVAL = 2 * BATTERY_WAKE_UPDATE_INTERVAL diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 3d66939a13c..d7e8817b1b7 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -142,7 +142,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] async def async_update(self) -> None: """Force full update from the generic entity update service.""" - self._host.last_wake = 0 + for channel in self._host.api.channels: + if self._host.api.supported(channel, "battery"): + self._host.last_wake[channel] = 0 await super().async_update() @@ -192,7 +194,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): hw_version=self._host.api.camera_hardware_version(dev_ch), sw_version=self._host.api.camera_sw_version(dev_ch), serial_number=self._host.api.camera_uid(dev_ch), - configuration_url=self._conf_url, + configuration_url=f"{self._conf_url}/?ch={dev_ch}", ) @property diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index c3a8d340501..39b58c92ac3 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -34,7 +34,15 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import ( + BATTERY_ALL_WAKE_UPDATE_INTERVAL, + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, + BATTERY_WAKE_UPDATE_INTERVAL, + CONF_BC_PORT, + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from .exceptions import ( PasswordIncompatible, ReolinkSetupException, @@ -52,10 +60,6 @@ POLL_INTERVAL_NO_PUSH = 5 LONG_POLL_COOLDOWN = 0.75 LONG_POLL_ERROR_COOLDOWN = 30 -# Conserve battery by not waking the battery cameras each minute during normal update -# Most props are cached in the Home Hub and updated, but some are skipped -BATTERY_WAKE_UPDATE_INTERVAL = 3600 # seconds - _LOGGER = logging.getLogger(__name__) @@ -95,7 +99,8 @@ class ReolinkHost: bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), ) - self.last_wake: float = 0 + self.last_wake: defaultdict[int, float] = defaultdict(float) + self.last_all_wake: float = 0 self.update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict( lambda: defaultdict(int) ) @@ -459,15 +464,34 @@ class ReolinkHost: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - wake = False - if time() - self.last_wake > BATTERY_WAKE_UPDATE_INTERVAL: + wake: dict[int, bool] = {} + now = time() + for channel in self._api.stream_channels: # wake the battery cameras for a complete update - wake = True - self.last_wake = time() + if not self._api.supported(channel, "battery"): + wake[channel] = True + elif ( + ( + not self._api.sleeping(channel) + and now - self.last_wake[channel] + > BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL + ) + or (now - self.last_wake[channel] > BATTERY_WAKE_UPDATE_INTERVAL) + or (now - self.last_all_wake > BATTERY_ALL_WAKE_UPDATE_INTERVAL) + ): + # let a waking update coincide with the camera waking up by itself unless it did not wake for BATTERY_WAKE_UPDATE_INTERVAL + wake[channel] = True + self.last_wake[channel] = now + else: + wake[channel] = False - for channel in self._api.channels: + # check privacy mode if enabled if self._api.baichuan.privacy_mode(channel): await self._api.baichuan.get_privacy_mode(channel) + + if all(wake.values()): + self.last_all_wake = now + if self._api.baichuan.privacy_mode(): return # API is shutdown, no need to check states diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index a6f0b59426a..5ae8b0305e4 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.13.3"] + "requirements": ["reolink-aio==0.13.5"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 092f0d4ddca..36a2f3c5489 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -7,6 +7,7 @@ import logging from reolink_aio.api import DUAL_LENS_MODELS from reolink_aio.enums import VodRequestType +from reolink_aio.typings import VOD_trigger from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings from homeassistant.components.media_player import MediaClass, MediaType @@ -27,6 +28,8 @@ from .views import async_generate_playback_proxy_url _LOGGER = logging.getLogger(__name__) +VOD_SPLIT_TIME = dt.timedelta(minutes=5) + async def async_get_media_source(hass: HomeAssistant) -> ReolinkVODMediaSource: """Set up camera media source.""" @@ -60,11 +63,13 @@ class ReolinkVODMediaSource(MediaSource): """Resolve media to a url.""" identifier = ["UNKNOWN"] if item.identifier is not None: - identifier = item.identifier.split("|", 5) + identifier = item.identifier.split("|", 6) if identifier[0] != "FILE": raise Unresolvable(f"Unknown media item '{item.identifier}'.") - _, config_entry_id, channel_str, stream_res, filename = identifier + _, config_entry_id, channel_str, stream_res, filename, start_time, end_time = ( + identifier + ) channel = int(channel_str) host = get_host(self.hass, config_entry_id) @@ -75,12 +80,19 @@ class ReolinkVODMediaSource(MediaSource): return VodRequestType.DOWNLOAD return VodRequestType.PLAYBACK if host.api.is_nvr: - return VodRequestType.FLV + return VodRequestType.NVR_DOWNLOAD return VodRequestType.RTMP vod_type = get_vod_type() - if vod_type in [VodRequestType.DOWNLOAD, VodRequestType.PLAYBACK]: + if vod_type == VodRequestType.NVR_DOWNLOAD: + filename = f"{start_time}_{end_time}" + + if vod_type in { + VodRequestType.DOWNLOAD, + VodRequestType.NVR_DOWNLOAD, + VodRequestType.PLAYBACK, + }: proxy_url = async_generate_playback_proxy_url( config_entry_id, channel, filename, stream_res, vod_type.value ) @@ -141,6 +153,26 @@ class ReolinkVODMediaSource(MediaSource): int(month_str), int(day_str), ) + if item_type == "EVE": + ( + _, + config_entry_id, + channel_str, + stream, + year_str, + month_str, + day_str, + event, + ) = identifier + return await self._async_generate_camera_files( + config_entry_id, + int(channel_str), + stream, + int(year_str), + int(month_str), + int(day_str), + event, + ) raise Unresolvable(f"Unknown media item '{item.identifier}' during browsing.") @@ -341,6 +373,7 @@ class ReolinkVODMediaSource(MediaSource): year: int, month: int, day: int, + event: str | None = None, ) -> BrowseMediaSource: """Return all recording files on a specific day of a Reolink camera.""" host = get_host(self.hass, config_entry_id) @@ -357,9 +390,34 @@ class ReolinkVODMediaSource(MediaSource): month, day, ) + event_trigger = VOD_trigger[event] if event is not None else None _, vod_files = await host.api.request_vod_files( - channel, start, end, stream=stream + channel, + start, + end, + stream=stream, + split_time=VOD_SPLIT_TIME, + trigger=event_trigger, ) + + if event is None and host.api.is_nvr and not host.api.is_hub: + triggers = VOD_trigger.NONE + for file in vod_files: + triggers |= file.triggers + + children.extend( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"EVE|{config_entry_id}|{channel}|{stream}|{year}|{month}|{day}|{trigger.name}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaType.PLAYLIST, + title=str(trigger.name).title(), + can_play=False, + can_expand=True, + ) + for trigger in triggers + ) + for file in vod_files: file_name = f"{file.start_time.time()} {file.duration}" if file.triggers != file.triggers.NONE: @@ -372,7 +430,7 @@ class ReolinkVODMediaSource(MediaSource): children.append( BrowseMediaSource( domain=DOMAIN, - identifier=f"FILE|{config_entry_id}|{channel}|{stream}|{file.file_name}", + identifier=f"FILE|{config_entry_id}|{channel}|{stream}|{file.file_name}|{file.start_time_id}|{file.end_time_id}", media_class=MediaClass.VIDEO, media_content_type=MediaType.VIDEO, title=file_name, @@ -386,6 +444,8 @@ class ReolinkVODMediaSource(MediaSource): ) if host.api.model in DUAL_LENS_MODELS: title = f"{host.api.camera_name(channel)} lens {channel} {res_name(stream)} {year}/{month}/{day}" + if event: + title = f"{title} {event.title()}" return BrowseMediaSource( domain=DOMAIN, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 8b7d276a9e3..94d2ee3cf27 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -30,8 +30,8 @@ "api_error": "API error occurred", "cannot_connect": "Failed to connect, check the IP address of the camera", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "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}", + "not_admin": "User needs to be admin, user \"{username}\" has authorization 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}. The streaming protocols necessitate these additional password restrictions.", "unknown": "[%key:common::config_flow::error::unknown%]", "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}" diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 44265244b18..7f062055f7e 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -52,6 +52,7 @@ class PlaybackProxyView(HomeAssistantView): verify_ssl=False, ssl_cipher=SSLCipherList.INSECURE, ) + self._vod_type: str | None = None async def get( self, @@ -68,6 +69,8 @@ class PlaybackProxyView(HomeAssistantView): filename_decoded = urlsafe_b64decode(filename.encode("utf-8")).decode("utf-8") ch = int(channel) + if self._vod_type is not None: + vod_type = self._vod_type try: host = get_host(self.hass, config_entry_id) except Unresolvable: @@ -127,6 +130,25 @@ class PlaybackProxyView(HomeAssistantView): "apolication/octet-stream", ]: err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}" + if ( + reolink_response.content_type == "video/x-flv" + and vod_type == VodRequestType.PLAYBACK.value + ): + # next time use DOWNLOAD immediately + self._vod_type = VodRequestType.DOWNLOAD.value + _LOGGER.debug( + "%s, retrying using download instead of playback cmd", err_str + ) + return await self.get( + request, + config_entry_id, + channel, + stream_res, + self._vod_type, + filename, + retry, + ) + _LOGGER.error(err_str) if reolink_response.content_type == "text/html": text = await reolink_response.text() @@ -140,7 +162,10 @@ class PlaybackProxyView(HomeAssistantView): reolink_response.reason, response_headers, ) - response_headers["Content-Type"] = "video/mp4" + if "Content-Type" not in response_headers: + response_headers["Content-Type"] = reolink_response.content_type + if response_headers["Content-Type"] == "apolication/octet-stream": + response_headers["Content-Type"] = "application/octet-stream" response = web.StreamResponse( status=reolink_response.status, diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index 4875a8f6cfa..4117b0ee35b 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -14,7 +14,6 @@ from homeassistant.components import websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.decorators import require_admin from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import Unauthorized from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, @@ -114,7 +113,7 @@ class RepairsFlowIndexView(FlowManagerIndexView): url = "/api/repairs/issues/fix" name = "api:repairs:issues:fix" - @require_admin(error=Unauthorized(permission=POLICY_EDIT)) + @require_admin(permission=POLICY_EDIT) @RequestDataValidator( vol.Schema( { @@ -149,12 +148,12 @@ class RepairsFlowResourceView(FlowManagerResourceView): url = "/api/repairs/issues/fix/{flow_id}" name = "api:repairs:issues:fix:resource" - @require_admin(error=Unauthorized(permission=POLICY_EDIT)) + @require_admin(permission=POLICY_EDIT) async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) - @require_admin(error=Unauthorized(permission=POLICY_EDIT)) + @require_admin(permission=POLICY_EDIT) async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index d413c25c8d4..3903ab8adfb 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -78,7 +78,6 @@ class RepetierSensor(SensorEntity): self._attributes: dict = {} self._temp_id = temp_id self._printer_id = printer_id - self._state = None self._attr_name = name self._attr_available = False @@ -88,17 +87,12 @@ class RepetierSensor(SensorEntity): """Return sensor attributes.""" return self._attributes - @property - def native_value(self): - """Return sensor state.""" - return self._state - @callback def update_callback(self): """Get new data and update state.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Connect update callbacks.""" self.async_on_remove( async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self.update_callback) @@ -115,14 +109,14 @@ class RepetierSensor(SensorEntity): self._attr_available = True return data - def update(self): + def update(self) -> None: """Update the sensor.""" if (data := self._get_data()) is None: return state = data.pop("state") _LOGGER.debug("Printer %s State %s", self.name, state) self._attributes.update(data) - self._state = state + self._attr_native_value = state class RepetierTempSensor(RepetierSensor): @@ -131,11 +125,11 @@ class RepetierTempSensor(RepetierSensor): @property def native_value(self): """Return sensor state.""" - if self._state is None: + if self._attr_native_value is None: return None - return round(self._state, 2) + return round(self._attr_native_value, 2) - def update(self): + def update(self) -> None: """Update the sensor.""" if (data := self._get_data()) is None: return @@ -143,7 +137,7 @@ class RepetierTempSensor(RepetierSensor): temp_set = data["temp_set"] _LOGGER.debug("Printer %s Setpoint: %s, Temp: %s", self.name, temp_set, state) self._attributes.update(data) - self._state = state + self._attr_native_value = state class RepetierJobSensor(RepetierSensor): @@ -152,9 +146,9 @@ class RepetierJobSensor(RepetierSensor): @property def native_value(self): """Return sensor state.""" - if self._state is None: + if self._attr_native_value is None: return None - return round(self._state, 2) + return round(self._attr_native_value, 2) class RepetierJobEndSensor(RepetierSensor): @@ -162,7 +156,7 @@ class RepetierJobEndSensor(RepetierSensor): _attr_device_class = SensorDeviceClass.TIMESTAMP - def update(self): + def update(self) -> None: """Update the sensor.""" if (data := self._get_data()) is None: return @@ -171,7 +165,7 @@ class RepetierJobEndSensor(RepetierSensor): print_time = data["print_time"] from_start = data["from_start"] time_end = start + round(print_time, 0) - self._state = dt_util.utc_from_timestamp(time_end) + self._attr_native_value = dt_util.utc_from_timestamp(time_end) remaining = print_time - from_start remaining_secs = int(round(remaining, 0)) _LOGGER.debug( @@ -186,14 +180,14 @@ class RepetierJobStartSensor(RepetierSensor): _attr_device_class = SensorDeviceClass.TIMESTAMP - def update(self): + def update(self) -> None: """Update the sensor.""" if (data := self._get_data()) is None: return job_name = data["job_name"] start = data["start"] from_start = data["from_start"] - self._state = dt_util.utc_from_timestamp(start) + self._attr_native_value = dt_util.utc_from_timestamp(start) elapsed_secs = int(round(from_start, 0)) _LOGGER.debug( "Job %s elapsed %s", diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 85195fb1581..d83a242ac71 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -16,8 +16,16 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, + EVENT_LOGGING_CHANGED, +) +from homeassistant.core import ( + CoreState, + Event, + HassJob, + HomeAssistant, + ServiceCall, + callback, ) -from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -41,6 +49,7 @@ from .entity import RflinkCommand from .utils import identify_event_type _LOGGER = logging.getLogger(__name__) +LIB_LOGGER = logging.getLogger("rflink") CONF_IGNORE_DEVICES = "ignore_devices" CONF_RECONNECT_INTERVAL = "reconnect_interval" @@ -277,4 +286,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.async_create_task(connect(), eager_start=False) async_dispatcher_connect(hass, SIGNAL_EVENT, event_callback) + + async def handle_logging_changed(_: Event) -> None: + """Handle logging changed event.""" + if LIB_LOGGER.isEnabledFor(logging.DEBUG): + await RflinkCommand.send_command("rfdebug", "on") + _LOGGER.info("RFDEBUG enabled") + else: + await RflinkCommand.send_command("rfdebug", "off") + _LOGGER.info("RFDEBUG disabled") + + # Listen to EVENT_LOGGING_CHANGED to manage the RFDEBUG + hass.bus.async_listen(EVENT_LOGGING_CHANGED, handle_logging_changed) + return True diff --git a/homeassistant/components/rfxtrx/device_trigger.py b/homeassistant/components/rfxtrx/device_trigger.py index 35c1944948b..fe9e0da0d52 100644 --- a/homeassistant/components/rfxtrx/device_trigger.py +++ b/homeassistant/components/rfxtrx/device_trigger.py @@ -97,7 +97,7 @@ async def async_attach_trigger( if config[CONF_TYPE] == CONF_TYPE_COMMAND: event_data["values"] = {"Command": config[CONF_SUBTYPE]} elif config[CONF_TYPE] == CONF_TYPE_STATUS: - event_data["values"] = {"Status": config[CONF_SUBTYPE]} + event_data["values"] = {"Sensor Status": config[CONF_SUBTYPE]} event_config = event_trigger.TRIGGER_SCHEMA( { diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 2d7e0b17da1..d1a3deafa71 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Sign-in with Ring account", + "title": "Sign in with Ring account", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index c3217d9334e..92f4f5a0434 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -156,7 +156,7 @@ class RMVDepartureSensor(SensorEntity): return self._name @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._state is not None diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 81b412c6770..6697779adf6 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> ) _LOGGER.debug("Getting home data") try: - home_data = await api_client.get_home_data_v2(user_data) + home_data = await api_client.get_home_data_v3(user_data) except RoborockInvalidCredentials as err: raise ConfigEntryAuthFailed( "Invalid credentials", diff --git a/homeassistant/components/rpi_camera/camera.py b/homeassistant/components/rpi_camera/camera.py index a8ebaaaca6f..0e4bb40919c 100644 --- a/homeassistant/components/rpi_camera/camera.py +++ b/homeassistant/components/rpi_camera/camera.py @@ -7,6 +7,7 @@ import os import shutil import subprocess from tempfile import NamedTemporaryFile +from typing import Any from homeassistant.components.camera import Camera from homeassistant.const import CONF_FILE_PATH, CONF_NAME, EVENT_HOMEASSISTANT_STOP @@ -87,11 +88,11 @@ def setup_platform( class RaspberryCamera(Camera): """Representation of a Raspberry Pi camera.""" - def __init__(self, device_info): + def __init__(self, device_info: dict[str, Any]) -> None: """Initialize Raspberry Pi camera component.""" super().__init__() - self._name = device_info[CONF_NAME] + self._attr_name = device_info[CONF_NAME] self._config = device_info # Kill if there's raspistill instance @@ -150,11 +151,6 @@ class RaspberryCamera(Camera): return file.read() @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def frame_interval(self): + def frame_interval(self) -> float: """Return the interval between frames of the stream.""" return self._config[CONF_TIMELAPSE] / 1000 diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 70fe7919edb..367542ca8c2 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import cast import xmlrpc.client import voluptuous as vol @@ -126,6 +127,9 @@ def format_speed(speed): return round(kb_spd, 2 if kb_spd < 0.1 else 1) +type RTorrentData = tuple[float, float, list, list, list, list, list] + + class RTorrentSensor(SensorEntity): """Representation of an rtorrent sensor.""" @@ -135,12 +139,12 @@ class RTorrentSensor(SensorEntity): """Initialize the sensor.""" self.entity_description = description self.client = rtorrent_client - self.data = None + self.data: RTorrentData | None = None self._attr_name = f"{client_name} {description.name}" self._attr_available = False - def update(self): + def update(self) -> None: """Get the latest data from rtorrent and updates the state.""" multicall = xmlrpc.client.MultiCall(self.client) multicall.throttle.global_up.rate() @@ -152,7 +156,7 @@ class RTorrentSensor(SensorEntity): multicall.d.multicall2("", "leeching", "d.down.rate=") try: - self.data = multicall() + self.data = cast(RTorrentData, multicall()) self._attr_available = True except (xmlrpc.client.ProtocolError, OSError) as ex: _LOGGER.error("Connection to rtorrent failed (%s)", ex) @@ -164,14 +168,16 @@ class RTorrentSensor(SensorEntity): all_torrents = self.data[2] stopped_torrents = self.data[3] complete_torrents = self.data[4] + up_torrents = self.data[5] + down_torrents = self.data[6] uploading_torrents = 0 - for up_torrent in self.data[5]: + for up_torrent in up_torrents: if up_torrent[0]: uploading_torrents += 1 downloading_torrents = 0 - for down_torrent in self.data[6]: + for down_torrent in down_torrents: if down_torrent[0]: downloading_torrents += 1 diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py deleted file mode 100644 index 0fc257c463f..00000000000 --- a/homeassistant/components/rtsp_to_webrtc/__init__.py +++ /dev/null @@ -1,123 +0,0 @@ -"""RTSPtoWebRTC integration with an external RTSPToWebRTC Server. - -WebRTC uses a direct communication from the client (e.g. a web browser) to a -camera device. Home Assistant acts as the signal path for initial set up, -passing through the client offer and returning a camera answer, then the client -and camera communicate directly. - -However, not all cameras natively support WebRTC. This integration is a shim -for camera devices that support RTSP streams only, relying on an external -server RTSPToWebRTC that is a proxy. Home Assistant does not participate in -the offer/answer SDP protocol, other than as a signal path pass through. - -Other integrations may use this integration with these steps: -- Check if this integration is loaded -- Call is_supported_stream_source for compatibility -- Call async_offer_for_stream_source to get back an answer for a client offer -""" - -from __future__ import annotations - -import asyncio -import logging - -from rtsp_to_webrtc.client import get_adaptive_client -from rtsp_to_webrtc.exceptions import ClientError, ResponseError -from rtsp_to_webrtc.interface import WebRTCClientInterface -from webrtc_models import RTCIceServer - -from homeassistant.components import camera -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "rtsp_to_webrtc" -DATA_SERVER_URL = "server_url" -DATA_UNSUB = "unsub" -TIMEOUT = 10 -CONF_STUN_SERVER = "stun_server" - -_DEPRECATED = "deprecated" - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up RTSPtoWebRTC from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - ir.async_create_issue( - hass, - DOMAIN, - _DEPRECATED, - breaks_in_ha_version="2025.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key=_DEPRECATED, - translation_placeholders={ - "go2rtc": "[go2rtc](https://www.home-assistant.io/integrations/go2rtc/)", - }, - ) - - client: WebRTCClientInterface - try: - async with asyncio.timeout(TIMEOUT): - client = await get_adaptive_client( - async_get_clientsession(hass), entry.data[DATA_SERVER_URL] - ) - except ResponseError as err: - raise ConfigEntryNotReady from err - except (TimeoutError, ClientError) as err: - raise ConfigEntryNotReady from err - - hass.data[DOMAIN][CONF_STUN_SERVER] = entry.options.get(CONF_STUN_SERVER) - if server := entry.options.get(CONF_STUN_SERVER): - - @callback - def get_servers() -> list[RTCIceServer]: - return [RTCIceServer(urls=[server])] - - entry.async_on_unload(camera.async_register_ice_servers(hass, get_servers)) - - async def async_offer_for_stream_source( - stream_source: str, - offer_sdp: str, - stream_id: str, - ) -> str: - """Handle the signal path for a WebRTC stream. - - This signal path is used to route the offer created by the client to the - proxy server that translates a stream to WebRTC. The communication for - the stream itself happens directly between the client and proxy. - """ - try: - async with asyncio.timeout(TIMEOUT): - return await client.offer_stream_id(stream_id, offer_sdp, stream_source) - except TimeoutError as err: - raise HomeAssistantError("Timeout talking to RTSPtoWebRTC server") from err - except ClientError as err: - raise HomeAssistantError(str(err)) from err - - entry.async_on_unload( - camera.async_register_rtsp_to_web_rtc_provider( - hass, DOMAIN, async_offer_for_stream_source - ) - ) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - if DOMAIN in hass.data: - del hass.data[DOMAIN] - ir.async_delete_issue(hass, DOMAIN, _DEPRECATED) - return True - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry when options change.""" - if hass.data[DOMAIN][CONF_STUN_SERVER] != entry.options.get(CONF_STUN_SERVER): - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/rtsp_to_webrtc/config_flow.py b/homeassistant/components/rtsp_to_webrtc/config_flow.py deleted file mode 100644 index 22502659757..00000000000 --- a/homeassistant/components/rtsp_to_webrtc/config_flow.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Config flow for RTSPtoWebRTC.""" - -from __future__ import annotations - -import logging -from typing import Any -from urllib.parse import urlparse - -import rtsp_to_webrtc -import voluptuous as vol - -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.service_info.hassio import HassioServiceInfo - -from . import CONF_STUN_SERVER, DATA_SERVER_URL, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema({vol.Required(DATA_SERVER_URL): str}) - - -class RTSPToWebRTCConfigFlow(ConfigFlow, domain=DOMAIN): - """RTSPtoWebRTC config flow.""" - - _hassio_discovery: dict[str, Any] - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure the RTSPtoWebRTC server url.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - if user_input is None: - return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) - - url = user_input[DATA_SERVER_URL] - result = urlparse(url) - if not all([result.scheme, result.netloc]): - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={DATA_SERVER_URL: "invalid_url"}, - ) - - if error_code := await self._test_connection(url): - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={"base": error_code}, - ) - - await self.async_set_unique_id(DOMAIN) - return self.async_create_entry( - title=url, - data={DATA_SERVER_URL: url}, - ) - - async def _test_connection(self, url: str) -> str | None: - """Test the connection and return any relevant errors.""" - client = rtsp_to_webrtc.client.Client(async_get_clientsession(self.hass), url) - try: - await client.heartbeat() - except rtsp_to_webrtc.exceptions.ResponseError as err: - _LOGGER.error("RTSPtoWebRTC server failure: %s", str(err)) - return "server_failure" - except rtsp_to_webrtc.exceptions.ClientError as err: - _LOGGER.error("RTSPtoWebRTC communication failure: %s", str(err)) - return "server_unreachable" - return None - - async def async_step_hassio( - self, discovery_info: HassioServiceInfo - ) -> ConfigFlowResult: - """Prepare configuration for the RTSPtoWebRTC server add-on discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - self._hassio_discovery = discovery_info.config - return await self.async_step_hassio_confirm() - - async def async_step_hassio_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm Add-on discovery.""" - errors = None - if user_input is not None: - # Validate server connection once user has confirmed - host = self._hassio_discovery[CONF_HOST] - port = self._hassio_discovery[CONF_PORT] - url = f"http://{host}:{port}" - if error_code := await self._test_connection(url): - return self.async_abort(reason=error_code) - - if user_input is None or errors: - # Show initial confirmation or errors from server validation - return self.async_show_form( - step_id="hassio_confirm", - description_placeholders={"addon": self._hassio_discovery["addon"]}, - errors=errors, - ) - - return self.async_create_entry( - title=self._hassio_discovery["addon"], - data={DATA_SERVER_URL: url}, - ) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create an options flow.""" - return OptionsFlowHandler() - - -class OptionsFlowHandler(OptionsFlow): - """RTSPtoWeb Options flow.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_STUN_SERVER, - description={ - "suggested_value": self.config_entry.options.get( - CONF_STUN_SERVER - ), - }, - ): str, - } - ), - ) diff --git a/homeassistant/components/rtsp_to_webrtc/diagnostics.py b/homeassistant/components/rtsp_to_webrtc/diagnostics.py deleted file mode 100644 index ab13e0a64ee..00000000000 --- a/homeassistant/components/rtsp_to_webrtc/diagnostics.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Diagnostics support for Nest.""" - -from __future__ import annotations - -from typing import Any - -from rtsp_to_webrtc import client - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - return dict(client.get_diagnostics()) diff --git a/homeassistant/components/rtsp_to_webrtc/manifest.json b/homeassistant/components/rtsp_to_webrtc/manifest.json deleted file mode 100644 index 27b9703d50e..00000000000 --- a/homeassistant/components/rtsp_to_webrtc/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "domain": "rtsp_to_webrtc", - "name": "RTSPtoWebRTC", - "codeowners": ["@allenporter"], - "config_flow": true, - "dependencies": ["camera"], - "documentation": "https://www.home-assistant.io/integrations/rtsp_to_webrtc", - "iot_class": "local_push", - "loggers": ["rtsp_to_webrtc"], - "requirements": ["rtsp-to-webrtc==0.5.1"] -} diff --git a/homeassistant/components/rtsp_to_webrtc/strings.json b/homeassistant/components/rtsp_to_webrtc/strings.json deleted file mode 100644 index c8dcbb7f462..00000000000 --- a/homeassistant/components/rtsp_to_webrtc/strings.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Configure RTSPtoWebRTC", - "description": "The RTSPtoWebRTC integration requires a server to translate RTSP streams into WebRTC. Enter the URL to the RTSPtoWebRTC server.", - "data": { - "server_url": "RTSPtoWebRTC server URL e.g. https://example.com" - } - }, - "hassio_confirm": { - "title": "RTSPtoWebRTC via Home Assistant add-on", - "description": "Do you want to configure Home Assistant to connect to the RTSPtoWebRTC server provided by the add-on: {addon}?" - } - }, - "error": { - "invalid_url": "Must be a valid RTSPtoWebRTC server URL e.g. https://example.com", - "server_failure": "RTSPtoWebRTC server returned an error. Check logs for more information.", - "server_unreachable": "Unable to communicate with RTSPtoWebRTC server. Check logs for more information." - }, - "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "server_failure": "[%key:component::rtsp_to_webrtc::config::error::server_failure%]", - "server_unreachable": "[%key:component::rtsp_to_webrtc::config::error::server_unreachable%]" - } - }, - "issues": { - "deprecated": { - "title": "The RTSPtoWebRTC integration is deprecated", - "description": "The RTSPtoWebRTC integration is deprecated and will be removed. Please use the {go2rtc} integration instead, which is enabled by default and provides a better experience. You only need to remove the RTSPtoWebRTC config entry." - } - }, - "options": { - "step": { - "init": { - "data": { - "stun_server": "Stun server address (host:port)" - } - } - } - } -} diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 1f68781a3a2..4241f39778c 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -2,148 +2,18 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine import logging -from typing import Any - -import voluptuous as vol from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady -from .const import ( - ATTR_API_KEY, - ATTR_SPEED, - DEFAULT_SPEED_LIMIT, - DOMAIN, - SERVICE_PAUSE, - SERVICE_RESUME, - SERVICE_SET_SPEED, -) from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator from .helpers import get_client PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -SERVICES = ( - SERVICE_PAUSE, - SERVICE_RESUME, - SERVICE_SET_SPEED, -) - -SERVICE_BASE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_API_KEY): cv.string, - } -) - -SERVICE_SPEED_SCHEMA = SERVICE_BASE_SCHEMA.extend( - { - vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.string, - } -) - -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - - -@callback -def async_get_entry_for_service_call( - hass: HomeAssistant, call: ServiceCall -) -> SabnzbdConfigEntry: - """Get the entry ID related to a service call (by device ID).""" - call_data_api_key = call.data[ATTR_API_KEY] - - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data[ATTR_API_KEY] == call_data_api_key: - return entry - - raise ValueError(f"No api for API key: {call_data_api_key}") - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the SabNzbd Component.""" - - @callback - def extract_api( - func: Callable[ - [ServiceCall, SabnzbdUpdateCoordinator], Coroutine[Any, Any, None] - ], - ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: - """Define a decorator to get the correct api for a service call.""" - - async def wrapper(call: ServiceCall) -> None: - """Wrap the service function.""" - config_entry = async_get_entry_for_service_call(hass, call) - coordinator = config_entry.runtime_data - - try: - await func(call, coordinator) - except Exception as err: - raise HomeAssistantError( - f"Error while executing {func.__name__}: {err}" - ) from err - - return wrapper - - @extract_api - async def async_pause_queue( - call: ServiceCall, coordinator: SabnzbdUpdateCoordinator - ) -> None: - ir.async_create_issue( - hass, - DOMAIN, - "pause_action_deprecated", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - breaks_in_ha_version="2025.6", - translation_key="pause_action_deprecated", - ) - await coordinator.sab_api.pause_queue() - - @extract_api - async def async_resume_queue( - call: ServiceCall, coordinator: SabnzbdUpdateCoordinator - ) -> None: - ir.async_create_issue( - hass, - DOMAIN, - "resume_action_deprecated", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - breaks_in_ha_version="2025.6", - translation_key="resume_action_deprecated", - ) - await coordinator.sab_api.resume_queue() - - @extract_api - async def async_set_queue_speed( - call: ServiceCall, coordinator: SabnzbdUpdateCoordinator - ) -> None: - ir.async_create_issue( - hass, - DOMAIN, - "set_speed_action_deprecated", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - breaks_in_ha_version="2025.6", - translation_key="set_speed_action_deprecated", - ) - speed = call.data.get(ATTR_SPEED) - await coordinator.sab_api.set_speed_limit(speed) - - for service, method, schema in ( - (SERVICE_PAUSE, async_pause_queue, SERVICE_BASE_SCHEMA), - (SERVICE_RESUME, async_resume_queue, SERVICE_BASE_SCHEMA), - (SERVICE_SET_SPEED, async_set_queue_speed, SERVICE_SPEED_SCHEMA), - ): - hass.services.async_register(DOMAIN, service, method, schema=schema) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool: """Set up the SabNzbd Component.""" diff --git a/homeassistant/components/sabnzbd/const.py b/homeassistant/components/sabnzbd/const.py index f05b3f19e98..66c71089b72 100644 --- a/homeassistant/components/sabnzbd/const.py +++ b/homeassistant/components/sabnzbd/const.py @@ -1,12 +1,3 @@ """Constants for the Sabnzbd component.""" DOMAIN = "sabnzbd" - -ATTR_SPEED = "speed" -ATTR_API_KEY = "api_key" - -DEFAULT_SPEED_LIMIT = "100" - -SERVICE_PAUSE = "pause" -SERVICE_RESUME = "resume" -SERVICE_SET_SPEED = "set_speed" diff --git a/homeassistant/components/sabnzbd/icons.json b/homeassistant/components/sabnzbd/icons.json index b0a72040b4b..b06a1e316a1 100644 --- a/homeassistant/components/sabnzbd/icons.json +++ b/homeassistant/components/sabnzbd/icons.json @@ -13,16 +13,5 @@ "default": "mdi:speedometer" } } - }, - "services": { - "pause": { - "service": "mdi:pause" - }, - "resume": { - "service": "mdi:play" - }, - "set_speed": { - "service": "mdi:speedometer" - } } } diff --git a/homeassistant/components/sabnzbd/quality_scale.yaml b/homeassistant/components/sabnzbd/quality_scale.yaml index a1d6fc076b2..7e2a8fe9e26 100644 --- a/homeassistant/components/sabnzbd/quality_scale.yaml +++ b/homeassistant/components/sabnzbd/quality_scale.yaml @@ -1,6 +1,9 @@ rules: # Bronze - action-setup: done + action-setup: + status: exempt + comment: | + The integration does not provide any actions. appropriate-polling: done brands: done common-modules: done @@ -10,7 +13,7 @@ rules: docs-actions: status: exempt comment: | - The integration has deprecated the actions, thus the documentation has been removed. + The integration does not provide any actions. docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -26,10 +29,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: | - Raise ServiceValidationError in async_get_entry_for_service_call. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/homeassistant/components/sabnzbd/services.yaml b/homeassistant/components/sabnzbd/services.yaml deleted file mode 100644 index f1eea1c9469..00000000000 --- a/homeassistant/components/sabnzbd/services.yaml +++ /dev/null @@ -1,23 +0,0 @@ -pause: - fields: - api_key: - required: true - selector: - text: -resume: - fields: - api_key: - required: true - selector: - text: -set_speed: - fields: - api_key: - required: true - selector: - text: - speed: - example: 100 - default: 100 - selector: - text: diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 0ac8b93c57f..601f1153b82 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -32,7 +32,7 @@ "name": "[%key:common::action::pause%]" }, "resume": { - "name": "[%key:component::sabnzbd::services::resume::name%]" + "name": "Resume" } }, "number": { @@ -76,56 +76,6 @@ } } }, - "services": { - "pause": { - "name": "[%key:common::action::pause%]", - "description": "Pauses downloads.", - "fields": { - "api_key": { - "name": "SABnzbd API key", - "description": "The SABnzbd API key to pause downloads." - } - } - }, - "resume": { - "name": "Resume", - "description": "Resumes downloads.", - "fields": { - "api_key": { - "name": "[%key:component::sabnzbd::services::pause::fields::api_key::name%]", - "description": "The SABnzbd API key to resume downloads." - } - } - }, - "set_speed": { - "name": "Set speed", - "description": "Sets the download speed limit.", - "fields": { - "api_key": { - "name": "[%key:component::sabnzbd::services::pause::fields::api_key::name%]", - "description": "The SABnzbd API key to set speed limit." - }, - "speed": { - "name": "Speed", - "description": "Speed limit. If specified as a number with no units, will be interpreted as a percent. If units are provided (e.g., 500K) will be interpreted absolutely." - } - } - } - }, - "issues": { - "pause_action_deprecated": { - "title": "SABnzbd pause action deprecated", - "description": "The 'Pause' action is deprecated and will be removed in a future version. Please use the 'Pause' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." - }, - "resume_action_deprecated": { - "title": "SABnzbd resume action deprecated", - "description": "The 'Resume' action is deprecated and will be removed in a future version. Please use the 'Resume' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." - }, - "set_speed_action_deprecated": { - "title": "SABnzbd set_speed action deprecated", - "description": "The 'Set speed' action is deprecated and will be removed in a future version. Please use the 'Speedlimit' number entity instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." - } - }, "exceptions": { "service_call_exception": { "message": "Unable to send command to SABnzbd due to a connection error, try again later" diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index e306e00691f..f7af5efc899 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -21,26 +21,19 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer -from .bridge import ( - SamsungTVBridge, - async_get_device_info, - mac_from_device_info, - model_requires_encryption, -) +from .bridge import SamsungTVBridge, mac_from_device_info, model_requires_encryption from .const import ( CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, ENTRY_RELOAD_COOLDOWN, - LEGACY_PORT, LOGGER, METHOD_ENCRYPTED_WEBSOCKET, - METHOD_LEGACY, UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) @@ -51,7 +44,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] @callback def _async_get_device_bridge( - hass: HomeAssistant, data: dict[str, Any] + hass: HomeAssistant, data: Mapping[str, Any] ) -> SamsungTVBridge: """Get device bridge.""" return SamsungTVBridge.get_bridge( @@ -178,40 +171,18 @@ async def _async_create_bridge_with_updated_data( hass: HomeAssistant, entry: SamsungTVConfigEntry ) -> SamsungTVBridge: """Create a bridge object and update any missing data in the config entry.""" - updated_data: dict[str, str | int] = {} + updated_data: dict[str, str] = {} host: str = entry.data[CONF_HOST] - port: int | None = entry.data.get(CONF_PORT) - method: str | None = entry.data.get(CONF_METHOD) - load_info_attempted = False + method: str = entry.data[CONF_METHOD] info: dict[str, Any] | None = None - if not port or not method: - LOGGER.debug("Attempting to get port or method for %s", host) - if method == METHOD_LEGACY: - port = LEGACY_PORT - else: - # When we imported from yaml we didn't setup the method - # because we didn't know it - _result, port, method, info = await async_get_device_info(hass, host) - load_info_attempted = True - if not port or not method: - raise ConfigEntryNotReady( - 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) - updated_data[CONF_PORT] = port - updated_data[CONF_METHOD] = method - - bridge = _async_get_device_bridge(hass, {**entry.data, **updated_data}) + bridge = _async_get_device_bridge(hass, entry.data) mac: str | None = entry.data.get(CONF_MAC) model: str | None = entry.data.get(CONF_MODEL) + # Incorrect MAC cleanup introduced in #110599, can be removed in 2026.3 mac_is_incorrectly_formatted = mac and dr.format_mac(mac) != mac - if ( - not mac or not model or mac_is_incorrectly_formatted - ) and not load_info_attempted: + if not mac or not model or mac_is_incorrectly_formatted: info = await bridge.async_device_info() if not mac or mac_is_incorrectly_formatted: diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index e782b1dfcd9..d8682856752 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -46,6 +46,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_component from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac @@ -53,6 +54,7 @@ from homeassistant.util import dt as dt_util from .const import ( CONF_SESSION_ID, + DOMAIN, ENCRYPTED_WEBSOCKET_PORT, LEGACY_PORT, LOGGER, @@ -371,9 +373,13 @@ class SamsungTVLegacyBridge(SamsungTVBridge): except (ConnectionClosed, BrokenPipeError): # BrokenPipe can occur when the commands is sent to fast self._remote = None - except (UnhandledResponse, AccessDenied): + except (UnhandledResponse, AccessDenied) as err: # We got a response so it's on. - LOGGER.debug("Failed sending command %s", key, exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_sending_command", + translation_placeholders={"error": repr(err), "host": self.host}, + ) from err except OSError: # Different reasons, e.g. hostname not resolveable pass @@ -630,14 +636,21 @@ class SamsungTVWSBridge( ) self._remote = None except ConnectionFailure as err: - LOGGER.warning( - ( + error_details = err.args[0] + if "ms.channel.timeOut" in (error_details := repr(err)): + # The websocket was connected, but the TV is probably asleep + LOGGER.debug( + "Channel timeout occurred trying to get remote for %s: %s", + self.host, + error_details, + ) + else: + LOGGER.warning( "Unexpected ConnectionFailure trying to get remote for %s, " - "please report this issue: %s" - ), - self.host, - repr(err), - ) + "please report this issue: %s", + self.host, + error_details, + ) self._remote = None except (WebSocketException, AsyncioTimeoutError, OSError) as err: LOGGER.debug("Failed to get remote for %s: %s", self.host, repr(err)) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 74915c9251b..dbde1ee1ef3 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -23,7 +23,6 @@ from homeassistant.const import ( CONF_MAC, CONF_METHOD, CONF_MODEL, - CONF_NAME, CONF_PIN, CONF_PORT, CONF_TOKEN, @@ -57,13 +56,12 @@ from .const import ( RESULT_INVALID_PIN, RESULT_NOT_SUPPORTED, RESULT_SUCCESS, - RESULT_UNKNOWN_HOST, SUCCESSFUL_RESULTS, UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) -DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}) +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) def _strip_uuid(udn: str) -> str: @@ -98,6 +96,7 @@ def _mac_is_same_with_incorrect_formatting( current_unformatted_mac: str, formatted_mac: str ) -> bool: """Check if two macs are the same but formatted incorrectly.""" + # Incorrect MAC cleanup introduced in #110599, can be removed in 2026.3 current_formatted_mac = format_mac(current_unformatted_mac) return ( current_formatted_mac == formatted_mac @@ -111,9 +110,11 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 MINOR_VERSION = 2 + _host: str + _bridge: SamsungTVBridge + def __init__(self) -> None: """Initialize flow.""" - self._host: str = "" self._mac: str | None = None self._udn: str | None = None self._upnp_udn: str | None = None @@ -126,20 +127,17 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self._name: str | None = None self._title: str = "" self._id: int | None = None - self._bridge: SamsungTVBridge | None = None self._device_info: dict[str, Any] | None = None self._authenticator: SamsungTVEncryptedWSAsyncAuthenticator | None = None def _base_config_entry(self) -> dict[str, Any]: """Generate the base config entry without the method.""" - assert self._bridge is not None return { CONF_HOST: self._host, CONF_MAC: self._mac, CONF_MANUFACTURER: self._manufacturer or DEFAULT_MANUFACTURER, CONF_METHOD: self._bridge.method, CONF_MODEL: self._model, - CONF_NAME: self._name, CONF_PORT: self._bridge.port, CONF_SSDP_RENDERING_CONTROL_LOCATION: self._ssdp_rendering_control_location, CONF_SSDP_MAIN_TV_AGENT_LOCATION: self._ssdp_main_tv_agent_location, @@ -147,7 +145,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): def _get_entry_from_bridge(self) -> ConfigFlowResult: """Get device entry.""" - assert self._bridge data = self._base_config_entry() if self._bridge.token: data[CONF_TOKEN] = self._bridge.token @@ -167,7 +164,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self, raise_on_progress: bool = True ) -> None: """Set the unique id from the udn.""" - assert self._host is not None # Set the unique id without raising on progress in case # there are two SSDP flows with for each ST await self.async_set_unique_id(self._udn, raise_on_progress=False) @@ -254,39 +250,44 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self._mac = mac return True - async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> None: + async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> bool: try: self._host = await self.hass.async_add_executor_job( socket.gethostbyname, user_input[CONF_HOST] ) except socket.gaierror as err: - raise AbortFlow(RESULT_UNKNOWN_HOST) from err - self._name = user_input.get(CONF_NAME, self._host) or "" - self._title = self._name + LOGGER.debug("Failed to get IP for %s: %s", user_input[CONF_HOST], err) + return False + self._title = self._host + return True async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" + errors: dict[str, str] | None = None if user_input is not None: - await self._async_set_name_host_from_input(user_input) - await self._async_create_bridge() - assert self._bridge - self._async_abort_entries_match({CONF_HOST: self._host}) - if self._bridge.method != METHOD_LEGACY: - # Legacy bridge does not provide device info - await self._async_set_device_unique_id(raise_on_progress=False) - if self._bridge.method == METHOD_ENCRYPTED_WEBSOCKET: - return await self.async_step_encrypted_pairing() - return await self.async_step_pairing({}) + if await self._async_set_name_host_from_input(user_input): + await self._async_create_bridge() + self._async_abort_entries_match({CONF_HOST: self._host}) + if self._bridge.method != METHOD_LEGACY: + # Legacy bridge does not provide device info + await self._async_set_device_unique_id(raise_on_progress=False) + if self._bridge.method == METHOD_ENCRYPTED_WEBSOCKET: + return await self.async_step_encrypted_pairing() + return await self.async_step_pairing({}) + errors = {"base": "invalid_host"} - return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), + errors=errors, + ) async def async_step_pairing( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a pairing by accepting the message on the TV.""" - assert self._bridge is not None errors: dict[str, str] = {} if user_input is not None: result = await self._bridge.async_try_connect() @@ -308,7 +309,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a encrypted pairing.""" - assert self._host is not None await self._async_start_encrypted_pairing(self._host) assert self._authenticator is not None errors: dict[str, str] = {} @@ -423,7 +423,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): @callback def _async_start_discovery_with_mac_address(self) -> None: """Start discovery.""" - assert self._host is not None if (entry := self._async_update_existing_matching_entry()) and entry.unique_id: # If we have the unique id and the mac we abort # as we do not need anything else @@ -521,7 +520,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): """Handle user-confirmation of discovered node.""" if user_input is not None: await self._async_create_bridge() - assert self._bridge if self._bridge.method == METHOD_ENCRYPTED_WEBSOCKET: return await self.async_step_encrypted_pairing() return await self.async_step_pairing({}) @@ -534,10 +532,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - if entry_data.get(CONF_MODEL) and entry_data.get(CONF_NAME): - self._title = f"{entry_data[CONF_NAME]} ({entry_data[CONF_MODEL]})" - else: - self._title = entry_data.get(CONF_NAME) or entry_data[CONF_HOST] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -570,11 +564,11 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): # On websocket we will get RESULT_CANNOT_CONNECT when auth is missing errors = {"base": RESULT_AUTH_MISSING} - self.context["title_placeholders"] = {"device": self._title} + self.context["title_placeholders"] = {"device": reauth_entry.title} return self.async_show_form( step_id="reauth_confirm", errors=errors, - description_placeholders={"device": self._title}, + description_placeholders={"device": reauth_entry.title}, ) async def _async_start_encrypted_pairing(self, host: str) -> None: @@ -611,10 +605,10 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): errors = {"base": RESULT_INVALID_PIN} - self.context["title_placeholders"] = {"device": self._title} + self.context["title_placeholders"] = {"device": reauth_entry.title} return self.async_show_form( step_id="reauth_confirm_encrypted", errors=errors, - description_placeholders={"device": self._title}, + description_placeholders={"device": reauth_entry.title}, data_schema=vol.Schema({vol.Required(CONF_PIN): str}), ) diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py index ed3c24946ab..9b09436be88 100644 --- a/homeassistant/components/samsungtv/coordinator.py +++ b/homeassistant/components/samsungtv/coordinator.py @@ -39,7 +39,7 @@ class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): ) self.bridge = bridge - self.is_on: bool | None = False + self.is_on: bool | None = None self.async_extra_update: Callable[[], Coroutine[Any, Any, None]] | None = None async def _async_update_data(self) -> None: @@ -52,7 +52,12 @@ class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): else: self.is_on = await self.bridge.async_is_on() if self.is_on != old_state: - LOGGER.debug("TV %s state updated to %s", self.bridge.host, self.is_on) + LOGGER.debug( + "TV %s state updated from %s to %s", + self.bridge.host, + old_state, + self.is_on, + ) if self.async_extra_update: await self.async_extra_update() diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 2126dae82f4..1918f6ef28c 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -12,7 +12,6 @@ from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_MODEL, - CONF_NAME, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -41,9 +40,7 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) # Fallback for legacy models that doesn't have a API to retrieve MAC or SerialNumber self._attr_unique_id = config_entry.unique_id or config_entry.entry_id self._attr_device_info = DeviceInfo( - name=config_entry.data.get(CONF_NAME), manufacturer=config_entry.data.get(CONF_MANUFACTURER), - model=config_entry.data.get(CONF_MODEL), model_id=config_entry.data.get(CONF_MODEL), ) if self.unique_id: @@ -57,7 +54,7 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) @property def available(self) -> bool: """Return the availability of the device.""" - if self._bridge.auth_failed: + if not super().available or self._bridge.auth_failed: return False return ( self.coordinator.is_on diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 4fb2e6bd1a2..fa4f04a97ec 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -29,13 +29,14 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.async_ import create_eager_task from .bridge import SamsungTVWSBridge -from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, LOGGER +from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, LOGGER from .coordinator import SamsungTVConfigEntry, SamsungTVDataUpdateCoordinator from .entity import SamsungTVEntity @@ -308,7 +309,12 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): try: await dmr_device.async_set_volume_level(volume) except UpnpActionResponseError as err: - LOGGER.warning("Unable to set volume level on %s: %r", self._host, err) + assert self._host + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_set_volume", + translation_placeholders={"error": repr(err), "host": self._host}, + ) from err async def async_volume_up(self) -> None: """Volume up the media player.""" @@ -380,4 +386,8 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): await self._async_send_keys([SOURCES[source]]) return - LOGGER.error("Unsupported source") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="source_unsupported", + translation_placeholders={"entity": self.entity_id, "source": source}, + ) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index 84e5fded03f..6251e65b2f8 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -43,6 +43,7 @@ }, "error": { "auth_missing": "[%key:component::samsungtv::config::abort::auth_missing%]", + "invalid_host": "Host is invalid, please try again.", "invalid_pin": "PIN is invalid, please try again." }, "abort": { @@ -52,7 +53,6 @@ "id_missing": "This Samsung device doesn't have a SerialNumber.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "not_supported": "This Samsung device is currently not supported.", - "unknown": "[%key:common::config_flow::error::unknown%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, @@ -68,11 +68,17 @@ "service_unsupported": { "message": "Entity {entity} does not support this action." }, + "source_unsupported": { + "message": "Entity {entity} does not support source {source}." + }, + "error_set_volume": { + "message": "Unable to set volume level on {host}: {error}" + }, + "error_sending_command": { + "message": "Unable to send command to {host}: {error}" + }, "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/schluter/climate.py b/homeassistant/components/schluter/climate.py index 7db15d3923c..581140d9406 100644 --- a/homeassistant/components/schluter/climate.py +++ b/homeassistant/components/schluter/climate.py @@ -118,12 +118,12 @@ class SchluterThermostat(CoordinatorEntity, ClimateEntity): return self.coordinator.data[self._serial_number].set_point_temp @property - def min_temp(self): + def min_temp(self) -> float: """Identify min_temp in Schluter API.""" return self.coordinator.data[self._serial_number].min_temp @property - def max_temp(self): + def max_temp(self) -> float: """Identify max_temp in Schluter API.""" return self.coordinator.data[self._serial_number].max_temp diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 27115836157..d46f63c9516 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -197,6 +197,7 @@ "state_class": { "options": { "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]", "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%]" } diff --git a/homeassistant/components/screenlogic/util.py b/homeassistant/components/screenlogic/util.py index 781d0fcab24..44fc8966b20 100644 --- a/homeassistant/components/screenlogic/util.py +++ b/homeassistant/components/screenlogic/util.py @@ -6,7 +6,7 @@ from screenlogicpy.const.data import SHARED_VALUES from homeassistant.helpers import entity_registry as er -from .const import DOMAIN as SL_DOMAIN, SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath +from .const import DOMAIN, SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -41,7 +41,7 @@ def cleanup_excluded_entity( entity_registry = er.async_get(coordinator.hass) unique_id = f"{coordinator.config_entry.unique_id}_{generate_unique_id(*data_path)}" if entity_id := entity_registry.async_get_entity_id( - platform_domain, SL_DOMAIN, unique_id + platform_domain, DOMAIN, unique_id ): _LOGGER.debug( "Removing existing entity '%s' per data inclusion rule", entity_id diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 4196106edd2..18f520f9a23 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -127,7 +127,7 @@ class SelectEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) entity_description: SelectEntityDescription - _attr_current_option: str | None + _attr_current_option: str | None = None _attr_options: list[str] _attr_state: None = None diff --git a/homeassistant/components/sense/strings.json b/homeassistant/components/sense/strings.json index 4579c84f050..c9ff5527940 100644 --- a/homeassistant/components/sense/strings.json +++ b/homeassistant/components/sense/strings.json @@ -10,7 +10,7 @@ } }, "validation": { - "title": "Sense Multi-factor authentication", + "title": "Sense multi-factor authentication", "data": { "code": "Verification code" } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index e06ee85cd03..9948860fd5f 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -38,6 +38,7 @@ from .const import ( # noqa: F401 ATTR_OPTIONS, ATTR_STATE_CLASS, CONF_STATE_CLASS, + DEFAULT_PRECISION_LIMIT, DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, DEVICE_CLASSES, @@ -48,6 +49,7 @@ from .const import ( # noqa: F401 STATE_CLASSES, STATE_CLASSES_SCHEMA, UNIT_CONVERTERS, + UNITS_PRECISION, SensorDeviceClass, SensorStateClass, ) @@ -137,6 +139,29 @@ def _numeric_state_expected( return device_class is not None +def _calculate_precision_from_ratio( + device_class: SensorDeviceClass, from_unit: str, to_unit: str, base_precision: int +) -> int | None: + """Calculate the precision for a unit conversion. + + Adjusts the base precision based on the ratio between the source and target units + for the given sensor device class. Returns the new precision or None if conversion + is not possible. + """ + if device_class not in UNIT_CONVERTERS: + return None + converter = UNIT_CONVERTERS[device_class] + + if from_unit not in converter.VALID_UNITS or to_unit not in converter.VALID_UNITS: + return None + + # Scale the precision when converting to a larger or smaller unit + # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh + ratio_log = log10(converter.get_unit_ratio(from_unit, to_unit)) + ratio_log = floor(ratio_log) if ratio_log > 0 else ceil(ratio_log) + return max(0, base_precision + ratio_log) + + CACHED_PROPERTIES_WITH_ATTR_ = { "device_class", "last_reset", @@ -663,30 +688,10 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): converter := UNIT_CONVERTERS.get(device_class) ): # Unit conversion needed - converted_numerical_value = converter.converter_factory( - native_unit_of_measurement, - unit_of_measurement, + value = converter.converter_factory( + native_unit_of_measurement, unit_of_measurement )(float(numerical_value)) - # If unit conversion is happening, and there's no rounding for display, - # do a best effort rounding here. - if ( - suggested_precision is None - and self._sensor_option_display_precision is None - ): - # Deduce the precision by finding the decimal point, if any - value_s = str(value) - # Scale the precision when converting to a larger unit - # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh - precision = ( - len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 - ) + converter.get_unit_floored_log_ratio( - native_unit_of_measurement, unit_of_measurement - ) - value = f"{converted_numerical_value:z.{precision}f}" - else: - value = converted_numerical_value - # Validate unit of measurement used for sensors with a device class if ( not self._invalid_unit_of_measurement_reported @@ -739,34 +744,78 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return cast(int, precision) return None - def _update_suggested_precision(self) -> None: - """Update suggested display precision stored in registry.""" - assert self.registry_entry + def _get_adjusted_display_precision(self) -> int | None: + """Return the display precision for the sensor. - device_class = self.device_class + When the integration has specified a suggested display precision, it will be used. + If a unit conversion is needed, the display precision will be adjusted based on + the ratio from the native unit to the current one. + + When the integration does not specify a suggested display precision, a default + device class precision will be used from UNITS_PRECISION, and the final precision + will be adjusted based on the ratio from the default unit to the current one. It + will also be capped so that the extra precision (from the base unit) does not + exceed DEFAULT_PRECISION_LIMIT. + """ display_precision = self.suggested_display_precision + device_class = self.device_class + if device_class is None: + return display_precision + default_unit_of_measurement = ( self.suggested_unit_of_measurement or self.native_unit_of_measurement ) + if default_unit_of_measurement is None: + return display_precision + unit_of_measurement = self.unit_of_measurement + if unit_of_measurement is None: + return display_precision - if ( - display_precision is not None - and default_unit_of_measurement != unit_of_measurement - and device_class in UNIT_CONVERTERS - ): - converter = UNIT_CONVERTERS[device_class] - - # Scale the precision when converting to a larger or smaller unit - # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh - ratio_log = log10( - converter.get_unit_ratio( - default_unit_of_measurement, unit_of_measurement + if display_precision is not None: + if default_unit_of_measurement != unit_of_measurement: + return ( + _calculate_precision_from_ratio( + device_class, + default_unit_of_measurement, + unit_of_measurement, + display_precision, + ) + or display_precision ) - ) - ratio_log = floor(ratio_log) if ratio_log > 0 else ceil(ratio_log) - display_precision = max(0, display_precision + ratio_log) + return display_precision + # Get the base unit and precision for the device class so we can use it to infer + # the display precision for the current unit + if device_class not in UNITS_PRECISION: + return None + device_class_base_unit, device_class_base_precision = UNITS_PRECISION[ + device_class + ] + + precision = ( + _calculate_precision_from_ratio( + device_class, + device_class_base_unit, + unit_of_measurement, + device_class_base_precision, + ) + if device_class_base_unit != unit_of_measurement + else device_class_base_precision + ) + if precision is None: + return None + + # Since we are inferring the precision from the device class, cap it to avoid + # having too many decimals + return min(precision, device_class_base_precision + DEFAULT_PRECISION_LIMIT) + + def _update_suggested_precision(self) -> None: + """Update suggested display precision stored in registry.""" + + display_precision = self._get_adjusted_display_precision() + + assert self.registry_entry sensor_options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) if "suggested_display_precision" not in sensor_options: if display_precision is None: diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index c845980e9df..994c29b6bbf 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, DEGREE, @@ -33,6 +34,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPrecipitationDepth, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfReactivePower, UnitOfSoundPressure, UnitOfSpeed, @@ -56,8 +58,10 @@ from homeassistant.util.unit_conversion import ( EnergyDistanceConverter, InformationConverter, MassConverter, + MassVolumeConcentrationConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -203,7 +207,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring energy by distance, for example the amount of electric energy consumed by an electric car. - Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + Unit of measurement: `kWh/100km`, `Wh/km`, `mi/kWh`, `km/kWh` """ ENERGY_STORAGE = "energy_storage" @@ -225,7 +229,7 @@ class SensorDeviceClass(StrEnum): """Gas. Unit of measurement: - - SI / metric: `m³` + - SI / metric: `L`, `m³` - USCS / imperial: `ft³`, `CCF` """ @@ -349,6 +353,12 @@ class SensorDeviceClass(StrEnum): - `psi` """ + REACTIVE_ENERGY = "reactive_energy" + """Reactive energy. + + Unit of measurement: `varh`, `kvarh` + """ + REACTIVE_POWER = "reactive_power" """Reactive power. @@ -392,7 +402,7 @@ class SensorDeviceClass(StrEnum): VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" """Amount of VOC. - Unit of measurement: `µg/m³` + Unit of measurement: `µg/m³`, `mg/m³` """ VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" @@ -529,8 +539,10 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.PRECIPITATION: DistanceConverter, SensorDeviceClass.PRECIPITATION_INTENSITY: SpeedConverter, SensorDeviceClass.PRESSURE: PressureConverter, + SensorDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter, SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: MassVolumeConcentrationConverter, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: UnitlessRatioConverter, SensorDeviceClass.VOLTAGE: ElectricPotentialConverter, SensorDeviceClass.VOLUME: VolumeConverter, @@ -571,6 +583,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, }, SensorDeviceClass.HUMIDITY: {PERCENTAGE}, SensorDeviceClass.ILLUMINANCE: {LIGHT_LUX}, @@ -596,6 +609,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_ENERGY: set(UnitOfReactiveEnergy), SensorDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), SensorDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, @@ -606,7 +620,8 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.TEMPERATURE: set(UnitOfTemperature), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, }, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: { CONCENTRATION_PARTS_PER_BILLION, @@ -628,6 +643,53 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.WIND_SPEED: set(UnitOfSpeed), } +# Maximum precision (decimals) deviation from default device class precision. +DEFAULT_PRECISION_LIMIT = 2 + +# Map one unit for each device class to its default precision. +# The biggest unit with the lowest precision should be used. For example, if W should +# have 0 decimals, that one should be used and not mW, even though mW also should have +# 0 decimals. Otherwise the smaller units will have more decimals than expected. +UNITS_PRECISION = { + SensorDeviceClass.APPARENT_POWER: (UnitOfApparentPower.VOLT_AMPERE, 0), + SensorDeviceClass.AREA: (UnitOfArea.SQUARE_CENTIMETERS, 0), + SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.PA, 0), + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: ( + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, + 0, + ), + SensorDeviceClass.CONDUCTIVITY: (UnitOfConductivity.MICROSIEMENS_PER_CM, 1), + SensorDeviceClass.CURRENT: (UnitOfElectricCurrent.MILLIAMPERE, 0), + SensorDeviceClass.DATA_RATE: (UnitOfDataRate.KILOBITS_PER_SECOND, 0), + SensorDeviceClass.DATA_SIZE: (UnitOfInformation.KILOBITS, 0), + SensorDeviceClass.DISTANCE: (UnitOfLength.CENTIMETERS, 0), + SensorDeviceClass.DURATION: (UnitOfTime.MILLISECONDS, 0), + SensorDeviceClass.ENERGY: (UnitOfEnergy.WATT_HOUR, 0), + SensorDeviceClass.ENERGY_DISTANCE: (UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, 0), + SensorDeviceClass.ENERGY_STORAGE: (UnitOfEnergy.WATT_HOUR, 0), + SensorDeviceClass.FREQUENCY: (UnitOfFrequency.HERTZ, 0), + SensorDeviceClass.GAS: (UnitOfVolume.MILLILITERS, 0), + SensorDeviceClass.IRRADIANCE: (UnitOfIrradiance.WATTS_PER_SQUARE_METER, 0), + SensorDeviceClass.POWER: (UnitOfPower.WATT, 0), + SensorDeviceClass.PRECIPITATION: (UnitOfPrecipitationDepth.CENTIMETERS, 0), + SensorDeviceClass.PRECIPITATION_INTENSITY: ( + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + 0, + ), + SensorDeviceClass.PRESSURE: (UnitOfPressure.PA, 0), + SensorDeviceClass.REACTIVE_POWER: (UnitOfReactivePower.VOLT_AMPERE_REACTIVE, 0), + SensorDeviceClass.SOUND_PRESSURE: (UnitOfSoundPressure.DECIBEL, 0), + SensorDeviceClass.SPEED: (UnitOfSpeed.MILLIMETERS_PER_SECOND, 0), + SensorDeviceClass.TEMPERATURE: (UnitOfTemperature.KELVIN, 1), + SensorDeviceClass.VOLTAGE: (UnitOfElectricPotential.VOLT, 0), + SensorDeviceClass.VOLUME: (UnitOfVolume.MILLILITERS, 0), + SensorDeviceClass.VOLUME_FLOW_RATE: (UnitOfVolumeFlowRate.LITERS_PER_SECOND, 0), + SensorDeviceClass.VOLUME_STORAGE: (UnitOfVolume.MILLILITERS, 0), + SensorDeviceClass.WATER: (UnitOfVolume.MILLILITERS, 0), + SensorDeviceClass.WEIGHT: (UnitOfMass.GRAMS, 0), + SensorDeviceClass.WIND_SPEED: (UnitOfSpeed.MILLIMETERS_PER_SECOND, 0), +} + DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, @@ -671,6 +733,10 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.PRECIPITATION: set(SensorStateClass), SensorDeviceClass.PRECIPITATION_INTENSITY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.PRESSURE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.REACTIVE_ENERGY: { + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, + }, SensorDeviceClass.REACTIVE_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.SIGNAL_STRENGTH: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.SOUND_PRESSURE: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index f52393f28ff..2b1eb350c3e 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -70,6 +70,7 @@ CONF_IS_PRECIPITATION = "is_precipitation" CONF_IS_PRECIPITATION_INTENSITY = "is_precipitation_intensity" CONF_IS_PRESSURE = "is_pressure" CONF_IS_SPEED = "is_speed" +CONF_IS_REACTIVE_ENERGY = "is_reactive_energy" CONF_IS_REACTIVE_POWER = "is_reactive_power" CONF_IS_SIGNAL_STRENGTH = "is_signal_strength" CONF_IS_SOUND_PRESSURE = "is_sound_pressure" @@ -128,6 +129,7 @@ ENTITY_CONDITIONS = { {CONF_TYPE: CONF_IS_PRECIPITATION_INTENSITY} ], SensorDeviceClass.PRESSURE: [{CONF_TYPE: CONF_IS_PRESSURE}], + SensorDeviceClass.REACTIVE_ENERGY: [{CONF_TYPE: CONF_IS_REACTIVE_ENERGY}], SensorDeviceClass.REACTIVE_POWER: [{CONF_TYPE: CONF_IS_REACTIVE_POWER}], SensorDeviceClass.SIGNAL_STRENGTH: [{CONF_TYPE: CONF_IS_SIGNAL_STRENGTH}], SensorDeviceClass.SOUND_PRESSURE: [{CONF_TYPE: CONF_IS_SOUND_PRESSURE}], @@ -193,6 +195,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_PRECIPITATION, CONF_IS_PRECIPITATION_INTENSITY, CONF_IS_PRESSURE, + CONF_IS_REACTIVE_ENERGY, CONF_IS_REACTIVE_POWER, CONF_IS_SIGNAL_STRENGTH, CONF_IS_SOUND_PRESSURE, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index dee48434294..d44611a49db 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -68,6 +68,7 @@ CONF_POWER_FACTOR = "power_factor" CONF_PRECIPITATION = "precipitation" CONF_PRECIPITATION_INTENSITY = "precipitation_intensity" CONF_PRESSURE = "pressure" +CONF_REACTIVE_ENERGY = "reactive_energy" CONF_REACTIVE_POWER = "reactive_power" CONF_SIGNAL_STRENGTH = "signal_strength" CONF_SOUND_PRESSURE = "sound_pressure" @@ -127,6 +128,7 @@ ENTITY_TRIGGERS = { {CONF_TYPE: CONF_PRECIPITATION_INTENSITY} ], SensorDeviceClass.PRESSURE: [{CONF_TYPE: CONF_PRESSURE}], + SensorDeviceClass.REACTIVE_ENERGY: [{CONF_TYPE: CONF_REACTIVE_ENERGY}], SensorDeviceClass.REACTIVE_POWER: [{CONF_TYPE: CONF_REACTIVE_POWER}], SensorDeviceClass.SIGNAL_STRENGTH: [{CONF_TYPE: CONF_SIGNAL_STRENGTH}], SensorDeviceClass.SOUND_PRESSURE: [{CONF_TYPE: CONF_SOUND_PRESSURE}], @@ -193,6 +195,7 @@ TRIGGER_SCHEMA = vol.All( CONF_PRECIPITATION, CONF_PRECIPITATION_INTENSITY, CONF_PRESSURE, + CONF_REACTIVE_ENERGY, CONF_REACTIVE_POWER, CONF_SIGNAL_STRENGTH, CONF_SOUND_PRESSURE, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 497c1544b3b..f412b5de253 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -15,6 +15,22 @@ "atmospheric_pressure": { "default": "mdi:thermometer-lines" }, + "battery": { + "default": "mdi:battery-unknown", + "range": { + "0": "mdi:battery-alert", + "10": "mdi:battery-10", + "20": "mdi:battery-20", + "30": "mdi:battery-30", + "40": "mdi:battery-40", + "50": "mdi:battery-50", + "60": "mdi:battery-60", + "70": "mdi:battery-70", + "80": "mdi:battery-80", + "90": "mdi:battery-90", + "100": "mdi:battery" + } + }, "blood_glucose_concentration": { "default": "mdi:spoon-sugar" }, @@ -114,6 +130,9 @@ "pressure": { "default": "mdi:gauge" }, + "reactive_energy": { + "default": "mdi:lightning-bolt" + }, "reactive_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 123c30da72e..ecaeb2504d9 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -38,6 +38,7 @@ "is_precipitation": "Current {entity_name} precipitation", "is_precipitation_intensity": "Current {entity_name} precipitation intensity", "is_pressure": "Current {entity_name} pressure", + "is_reactive_energy": "Current {entity_name} reactive energy", "is_reactive_power": "Current {entity_name} reactive power", "is_signal_strength": "Current {entity_name} signal strength", "is_sound_pressure": "Current {entity_name} sound pressure", @@ -92,6 +93,7 @@ "precipitation": "{entity_name} precipitation changes", "precipitation_intensity": "{entity_name} precipitation intensity changes", "pressure": "{entity_name} pressure changes", + "reactive_energy": "{entity_name} reactive energy changes", "reactive_power": "{entity_name} reactive power changes", "signal_strength": "{entity_name} signal strength changes", "sound_pressure": "{entity_name} sound pressure changes", @@ -133,6 +135,7 @@ "name": "State class", "state": { "measurement": "Measurement", + "measurement_angle": "Measurement angle", "total": "Total", "total_increasing": "Total increasing" } @@ -256,6 +259,9 @@ "pressure": { "name": "Pressure" }, + "reactive_energy": { + "name": "Reactive energy" + }, "reactive_power": { "name": "Reactive power" }, diff --git a/homeassistant/components/sensorpro/manifest.json b/homeassistant/components/sensorpro/manifest.json index ccf042245ea..1a6ec5527a0 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.7.0"] + "requirements": ["sensorpro-ble==0.7.1"] } diff --git a/homeassistant/components/seven_segments/image_processing.py b/homeassistant/components/seven_segments/image_processing.py index bda17b75081..29ebe8f03ea 100644 --- a/homeassistant/components/seven_segments/image_processing.py +++ b/homeassistant/components/seven_segments/image_processing.py @@ -70,19 +70,24 @@ class ImageProcessingSsocr(ImageProcessingEntity): _attr_device_class = ImageProcessingDeviceClass.OCR - def __init__(self, hass, camera_entity, config, name): + def __init__( + self, + hass: HomeAssistant, + camera_entity: str, + config: ConfigType, + name: str | None, + ) -> None: """Initialize seven segments processing.""" - self.hass = hass - self._camera_entity = camera_entity + self._attr_camera_entity = camera_entity if name: - self._name = name + self._attr_name = name else: - self._name = f"SevenSegment OCR {split_entity_id(camera_entity)[1]}" - self._state = None + self._attr_name = f"SevenSegment OCR {split_entity_id(camera_entity)[1]}" + self._attr_state = None self.filepath = os.path.join( - self.hass.config.config_dir, - f"ssocr-{self._name.replace(' ', '_')}.png", + hass.config.config_dir, + f"ssocr-{self._attr_name.replace(' ', '_')}.png", ) crop = [ "crop", @@ -106,22 +111,7 @@ class ImageProcessingSsocr(ImageProcessingEntity): ] self._command.append(self.filepath) - @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera_entity - - @property - def name(self): - """Return the name of the image processor.""" - return self._name - - @property - def state(self): - """Return the state of the entity.""" - return self._state - - def process_image(self, image): + def process_image(self, image: bytes) -> None: """Process the image.""" stream = io.BytesIO(image) img = Image.open(stream) @@ -135,9 +125,9 @@ class ImageProcessingSsocr(ImageProcessingEntity): ) as ocr: out = ocr.communicate() if out[0] != b"": - self._state = out[0].strip().decode("utf-8") + self._attr_state = out[0].strip().decode("utf-8") else: - self._state = None + self._attr_state = None _LOGGER.warning( "Unable to detect value: %s", out[1].strip().decode("utf-8") ) diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py index 19e2d3083c9..988a01f0022 100644 --- a/homeassistant/components/seventeentrack/const.py +++ b/homeassistant/components/seventeentrack/const.py @@ -42,8 +42,10 @@ NOTIFICATION_DELIVERED_MESSAGE = ( VALUE_DELIVERED = "Delivered" SERVICE_GET_PACKAGES = "get_packages" +SERVICE_ADD_PACKAGE = "add_package" SERVICE_ARCHIVE_PACKAGE = "archive_package" ATTR_PACKAGE_STATE = "package_state" ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number" +ATTR_PACKAGE_FRIENDLY_NAME = "package_friendly_name" ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/seventeentrack/icons.json b/homeassistant/components/seventeentrack/icons.json index c48e147e973..5ddfaacc8ac 100644 --- a/homeassistant/components/seventeentrack/icons.json +++ b/homeassistant/components/seventeentrack/icons.json @@ -31,6 +31,9 @@ "get_packages": { "service": "mdi:package" }, + "add_package": { + "service": "mdi:package" + }, "archive_package": { "service": "mdi:archive" } diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 54c23e6d619..5ba0b569b19 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -23,6 +23,7 @@ from .const import ( ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, ATTR_ORIGIN_COUNTRY, + ATTR_PACKAGE_FRIENDLY_NAME, ATTR_PACKAGE_STATE, ATTR_PACKAGE_TRACKING_NUMBER, ATTR_PACKAGE_TYPE, @@ -31,11 +32,12 @@ from .const import ( ATTR_TRACKING_INFO_LANGUAGE, ATTR_TRACKING_NUMBER, DOMAIN, + SERVICE_ADD_PACKAGE, SERVICE_ARCHIVE_PACKAGE, SERVICE_GET_PACKAGES, ) -SERVICE_ADD_PACKAGES_SCHEMA: Final = vol.Schema( +SERVICE_GET_PACKAGES_SCHEMA: Final = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, vol.Optional(ATTR_PACKAGE_STATE): selector.SelectSelector( @@ -52,6 +54,14 @@ SERVICE_ADD_PACKAGES_SCHEMA: Final = vol.Schema( } ) +SERVICE_ADD_PACKAGE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_PACKAGE_TRACKING_NUMBER): cv.string, + vol.Required(ATTR_PACKAGE_FRIENDLY_NAME): cv.string, + } +) + SERVICE_ARCHIVE_PACKAGE_SCHEMA: Final = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, @@ -87,6 +97,22 @@ def setup_services(hass: HomeAssistant) -> None: ] } + async def add_package(call: ServiceCall) -> None: + """Add a new package to 17Track.""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] + friendly_name = call.data[ATTR_PACKAGE_FRIENDLY_NAME] + + await _validate_service(config_entry_id) + + seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ + config_entry_id + ] + + await seventeen_coordinator.client.profile.add_package( + tracking_number, friendly_name + ) + async def archive_package(call: ServiceCall) -> None: config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] @@ -138,10 +164,17 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_GET_PACKAGES, get_packages, - schema=SERVICE_ADD_PACKAGES_SCHEMA, + schema=SERVICE_GET_PACKAGES_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_ADD_PACKAGE, + add_package, + schema=SERVICE_ADD_PACKAGE_SCHEMA, + ) + hass.services.async_register( DOMAIN, SERVICE_ARCHIVE_PACKAGE, diff --git a/homeassistant/components/seventeentrack/services.yaml b/homeassistant/components/seventeentrack/services.yaml index 45d7c0a530a..2ea5658b149 100644 --- a/homeassistant/components/seventeentrack/services.yaml +++ b/homeassistant/components/seventeentrack/services.yaml @@ -18,6 +18,22 @@ get_packages: selector: config_entry: integration: seventeentrack +add_package: + fields: + package_tracking_number: + required: true + selector: + text: + package_friendly_name: + required: true + selector: + text: + config_entry_id: + required: true + selector: + config_entry: + integration: seventeentrack + archive_package: fields: package_tracking_number: diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index c95a553ae7b..bffb21cbfbd 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -80,6 +80,24 @@ } } }, + "add_package": { + "name": "Add a package", + "description": "Adds a package using the 17track API.", + "fields": { + "package_tracking_number": { + "name": "Package tracking number to add", + "description": "The package with the tracking number will be added." + }, + "package_friendly_name": { + "name": "Package friendly name", + "description": "The friendly name of the package to be added." + }, + "config_entry_id": { + "name": "17Track service", + "description": "The selected service to add the package to." + } + } + }, "archive_package": { "name": "Archive package", "description": "Archives a package using the 17track API.", diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 3130acff538..75fedf9b16d 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -64,6 +64,7 @@ from .utils import ( get_http_port, get_rpc_scripts_event_types, get_ws_context, + remove_stale_blu_trv_devices, ) PLATFORMS: Final = [ @@ -300,6 +301,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) runtime_data.rpc_script_events = await get_rpc_scripts_event_types( device, ignore_scripts=[BLE_SCRIPT_NAME] ) + remove_stale_blu_trv_devices(hass, device, entry) except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err: await device.shutdown() raise ConfigEntryNotReady( diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index b74578f1fb3..e7d7b46b322 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -36,12 +35,15 @@ from .entity import ( ) from .utils import ( async_remove_orphaned_entities, + get_blu_trv_device_info, get_device_entry_gen, get_virtual_component_ids, is_block_momentary_input, is_rpc_momentary_input, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class BlockBinarySensorDescription( @@ -85,8 +87,8 @@ class RpcBluTrvBinarySensor(RpcBinarySensor): super().__init__(coordinator, key, attribute, description) ble_addr: str = coordinator.device.config[key]["addr"] - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_BLUETOOTH, ble_addr)} + self._attr_device_info = get_blu_trv_device_info( + coordinator.device.config[key], ble_addr, coordinator.mac ) @@ -188,7 +190,6 @@ RPC_SENSORS: Final = { "input": RpcBinarySensorDescription( key="input", sub_key="state", - name="Input", device_class=BinarySensorDeviceClass.POWER, entity_registry_enabled_default=False, removal_condition=is_rpc_momentary_input, @@ -262,7 +263,6 @@ RPC_SENSORS: Final = { "boolean": RpcBinarySensorDescription( key="boolean", sub_key="value", - has_entity_name=True, ), "calibration": RpcBinarySensorDescription( key="blutrv", diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 06dffba5ead..eab7514514d 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Final -from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY, RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( @@ -19,18 +19,22 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import ( - CONNECTION_BLUETOOTH, - CONNECTION_NETWORK_MAC, - DeviceInfo, -) +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .utils import get_device_entry_gen, get_rpc_key_ids +from .utils import ( + get_block_device_info, + get_blu_trv_device_info, + get_device_entry_gen, + get_rpc_device_info, + get_rpc_key_ids, +) + +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -58,7 +62,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ translation_key="self_test", entity_category=EntityCategory.DIAGNOSTIC, press_action="trigger_shelly_gas_self_test", - supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, + supported=lambda coordinator: coordinator.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( key="mute", @@ -66,7 +70,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ translation_key="mute", entity_category=EntityCategory.CONFIG, press_action="trigger_shelly_gas_mute", - supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, + supported=lambda coordinator: coordinator.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( key="unmute", @@ -74,7 +78,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ translation_key="unmute", entity_category=EntityCategory.CONFIG, press_action="trigger_shelly_gas_unmute", - supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, + supported=lambda coordinator: coordinator.model in SHELLY_GAS_MODELS, ), ] @@ -85,7 +89,7 @@ BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [ translation_key="calibrate", entity_category=EntityCategory.CONFIG, press_action="trigger_blu_trv_calibration", - supported=lambda coordinator: coordinator.device.model == MODEL_BLU_GATEWAY, + supported=lambda coordinator: coordinator.model == MODEL_BLU_GATEWAY_G3, ), ] @@ -156,6 +160,7 @@ async def async_setup_entry( ShellyBluTrvButton(coordinator, button, id_) for id_ in blutrv_key_ids for button in BLU_TRV_BUTTONS + if button.supported(coordinator) ) async_add_entities(entities) @@ -166,6 +171,7 @@ class ShellyBaseButton( ): """Defines a Shelly base button.""" + _attr_has_entity_name = True entity_description: ShellyButtonDescription[ ShellyRpcCoordinator | ShellyBlockCoordinator ] @@ -226,8 +232,15 @@ class ShellyButton(ShellyBaseButton): """Initialize Shelly button.""" super().__init__(coordinator, description) - self._attr_name = f"{coordinator.device.name} {description.name}" self._attr_unique_id = f"{coordinator.mac}_{description.key}" + if isinstance(coordinator, ShellyBlockCoordinator): + self._attr_device_info = get_block_device_info( + coordinator.device, coordinator.mac + ) + else: + self._attr_device_info = get_rpc_device_info( + coordinator.device, coordinator.mac + ) self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) @@ -254,15 +267,11 @@ class ShellyBluTrvButton(ShellyBaseButton): """Initialize.""" super().__init__(coordinator, description) - ble_addr: str = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["addr"] - device_name = ( - coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["name"] - or f"shellyblutrv-{ble_addr.replace(':', '')}" - ) - self._attr_name = f"{device_name} {description.name}" + config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] + ble_addr: str = config["addr"] self._attr_unique_id = f"{ble_addr}_{description.key}" - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_BLUETOOTH, ble_addr)} + self._attr_device_info = get_blu_trv_device_info( + config, ble_addr, coordinator.mac ) self._id = id_ diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index f8cdb13ba9f..26fabe7e8b5 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,7 +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, RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -22,11 +22,6 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.device_registry import ( - CONNECTION_BLUETOOTH, - CONNECTION_NETWORK_MAC, - DeviceInfo, -) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity @@ -46,11 +41,16 @@ from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoo from .entity import ShellyRpcEntity, rpc_call from .utils import ( async_remove_shelly_entity, + get_block_device_info, + get_block_entity_name, + get_blu_trv_device_info, get_device_entry_gen, get_rpc_key_ids, is_rpc_thermostat_internal_actuator, ) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -179,6 +179,7 @@ class BlockSleepingClimate( ) _attr_target_temperature_step = SHTRV_01_TEMPERATURE_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True def __init__( self, @@ -197,7 +198,6 @@ class BlockSleepingClimate( self.last_state_attributes: Mapping[str, Any] self._preset_modes: list[str] = [] self._last_target_temp = SHTRV_01_TEMPERATURE_SETTINGS["default"] - self._attr_name = coordinator.name if self.block is not None and self.device_block is not None: self._unique_id = f"{self.coordinator.mac}-{self.block.description}" @@ -210,8 +210,11 @@ class BlockSleepingClimate( ] elif entry is not None: self._unique_id = entry.unique_id - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}, + self._attr_device_info = get_block_device_info( + coordinator.device, coordinator.mac, sensor_block + ) + self._attr_name = get_block_entity_name( + self.coordinator.device, sensor_block, None ) self._channel = cast(int, self._unique_id.split("_")[1]) @@ -551,7 +554,6 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_target_temperature_step = BLU_TRV_TEMPERATURE_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize.""" @@ -561,19 +563,9 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] ble_addr: str = self._config["addr"] self._attr_unique_id = f"{ble_addr}-{self.key}" - name = self._config["name"] or f"shellyblutrv-{ble_addr.replace(':', '')}" - model_id = self._config.get("local_name") - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_BLUETOOTH, ble_addr)}, - identifiers={(DOMAIN, ble_addr)}, - via_device=(DOMAIN, self.coordinator.mac), - manufacturer="Shelly", - model=BLU_TRV_MODEL_NAME.get(model_id), - model_id=model_id, - name=name, + self._attr_device_info = get_blu_trv_device_info( + self._config, ble_addr, self.coordinator.mac ) - # Added intentionally to the constructor to avoid double name from base class - self._attr_name = None @property def target_temperature(self) -> float | None: diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 87fc50a6666..7462766e2d4 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -258,6 +258,7 @@ DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( CONF_GEN = "gen" +VIRTUAL_COMPONENTS = ("boolean", "enum", "input", "number", "text") VIRTUAL_COMPONENTS_MAP = { "binary_sensor": {"types": ["boolean"], "modes": ["label"]}, "number": {"types": ["number"], "modes": ["field", "slider"]}, @@ -285,3 +286,5 @@ ROLE_TO_DEVICE_CLASS_MAP = { # We want to check only the first 5 KB of the script if it contains emitEvent() # so that the integration startup remains fast. MAX_SCRIPT_SIZE = 5120 + +All_LIGHT_TYPES = ("cct", "light", "rgb", "rgbw") diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index e9eb5acf161..d603636644b 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -21,6 +21,8 @@ from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoo from .entity import ShellyBlockEntity, ShellyRpcEntity from .utils import get_device_entry_gen, get_rpc_key_ids +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 806f5fea700..1b0078890af 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -13,7 +13,6 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry @@ -24,7 +23,9 @@ from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .utils import ( async_remove_shelly_entity, + get_block_device_info, get_block_entity_name, + get_rpc_device_info, get_rpc_entity_name, get_rpc_key_instances, ) @@ -353,13 +354,15 @@ def rpc_call[_T: ShellyRpcEntity, **_P]( class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): """Helper class to represent a block entity.""" + _attr_has_entity_name = True + def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: """Initialize Shelly entity.""" super().__init__(coordinator) self.block = block self._attr_name = get_block_entity_name(coordinator.device, block) - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + self._attr_device_info = get_block_device_info( + coordinator.device, coordinator.mac, block ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" @@ -395,12 +398,14 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Helper class to represent a rpc entity.""" + _attr_has_entity_name = True + def __init__(self, coordinator: ShellyRpcCoordinator, key: str) -> None: """Initialize Shelly entity.""" super().__init__(coordinator) self.key = key - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + self._attr_device_info = get_rpc_device_info( + coordinator.device, coordinator.mac, key ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -497,6 +502,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity): class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): """Class to load info from REST.""" + _attr_has_entity_name = True entity_description: RestEntityDescription def __init__( @@ -514,8 +520,8 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): coordinator.device, None, description.name ) self._attr_unique_id = f"{coordinator.mac}-{attribute}" - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + self._attr_device_info = get_block_device_info( + coordinator.device, coordinator.mac ) self._last_value = None @@ -623,8 +629,8 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.block: Block | None = block # type: ignore[assignment] self.entity_description = description - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + self._attr_device_info = get_block_device_info( + coordinator.device, coordinator.mac, block ) if block is not None: @@ -632,7 +638,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): f"{self.coordinator.mac}-{block.description}-{attribute}" ) self._attr_name = get_block_entity_name( - self.coordinator.device, block, self.entity_description.name + coordinator.device, block, description.name ) elif entry is not None: self._attr_unique_id = entry.unique_id @@ -691,8 +697,8 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.attribute = attribute self.entity_description = description - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + self._attr_device_info = get_rpc_device_info( + coordinator.device, coordinator.mac, key ) self._attr_unique_id = self._attr_unique_id = ( f"{coordinator.mac}-{key}-{attribute}" diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index ec5810581b1..677ea1f6138 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -17,7 +17,6 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -32,12 +31,15 @@ from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, + get_rpc_device_info, get_rpc_entity_name, get_rpc_key_instances, is_block_momentary_input, is_rpc_momentary_input, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class ShellyBlockEventDescription(EventEntityDescription): @@ -75,7 +77,6 @@ SCRIPT_EVENT: Final = ShellyRpcEventDescription( translation_key="script", device_class=None, entity_registry_enabled_default=False, - has_entity_name=True, ) @@ -193,6 +194,7 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity): class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): """Represent RPC event entity.""" + _attr_has_entity_name = True entity_description: ShellyRpcEventDescription def __init__( @@ -204,8 +206,8 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): """Initialize Shelly entity.""" super().__init__(coordinator) self.event_id = int(key.split(":")[-1]) - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + self._attr_device_info = get_rpc_device_info( + coordinator.device, coordinator.mac, key ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index ce31533b557..f5cffe37d5a 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -49,6 +49,8 @@ from .utils import ( percentage_to_brightness, ) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index e18cd7ca465..e10b5cb57cf 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -43,7 +43,7 @@ def async_describe_events( rpc_coordinator = get_rpc_coordinator_by_device_id(hass, device_id) if rpc_coordinator and rpc_coordinator.device.initialized: key = f"input:{channel - 1}" - input_name = get_rpc_entity_name(rpc_coordinator.device, key) + input_name = f"{rpc_coordinator.device.name} {get_rpc_entity_name(rpc_coordinator.device, key)}" elif click_type in BLOCK_INPUTS_EVENTS_TYPES: block_coordinator = get_block_coordinator_by_device_id(hass, device_id) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index f60718beca3..78e01e6d8a6 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,6 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], + "quality_scale": "silver", "requirements": ["aioshelly==13.6.0"], "zeroconf": [ { diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index ab09ad1976a..e406d63bdc2 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -21,7 +21,6 @@ from homeassistant.components.number import ( from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry @@ -38,10 +37,13 @@ from .entity import ( ) from .utils import ( async_remove_orphaned_entities, + get_blu_trv_device_info, get_device_entry_gen, get_virtual_component_ids, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): @@ -122,8 +124,8 @@ class RpcBluTrvNumber(RpcNumber): super().__init__(coordinator, key, attribute, description) ble_addr: str = coordinator.device.config[key]["addr"] - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_BLUETOOTH, ble_addr)} + self._attr_device_info = get_blu_trv_device_info( + coordinator.device.config[key], ble_addr, coordinator.mac ) @@ -181,7 +183,6 @@ RPC_NUMBERS: Final = { "number": RpcNumberDescription( key="number", sub_key="value", - has_entity_name=True, max_fn=lambda config: config["max"], min_fn=lambda config: config["min"], mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( diff --git a/homeassistant/components/shelly/quality_scale.yaml b/homeassistant/components/shelly/quality_scale.yaml index 8fec824bcc1..39667b556dd 100644 --- a/homeassistant/components/shelly/quality_scale.yaml +++ b/homeassistant/components/shelly/quality_scale.yaml @@ -17,7 +17,7 @@ rules: docs-removal-instructions: done entity-event-setup: done entity-unique-id: done - has-entity-name: todo + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -33,7 +33,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done @@ -42,7 +42,7 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done docs-known-limitations: done docs-supported-devices: done @@ -56,13 +56,13 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: todo - exception-translations: todo - icon-translations: todo + exception-translations: done + icon-translations: done reconfiguration-flow: done repair-issues: done stale-devices: - status: todo - comment: BLU TRV needs to be removed when un-paired + status: done + comment: BLU TRV is removed when un-paired # Platinum async-dependency: done diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index 98d374b496d..0e367a9df37 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -28,6 +28,8 @@ from .utils import ( get_virtual_component_ids, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RpcSelectDescription(RpcEntityDescription, SelectEntityDescription): @@ -38,7 +40,6 @@ RPC_SELECT_ENTITIES: Final = { "enum": RpcSelectDescription( key="enum", sub_key="value", - has_entity_name=True, ), } diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 79e4c97aead..0ea246c7734 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -34,7 +34,6 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType @@ -56,13 +55,17 @@ from .entity import ( ) from .utils import ( async_remove_orphaned_entities, + get_blu_trv_device_info, get_device_entry_gen, get_device_uptime, + get_rpc_device_info, get_shelly_air_lamp_life, get_virtual_component_ids, is_rpc_wifi_stations_disabled, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription): @@ -74,6 +77,7 @@ class RpcSensorDescription(RpcEntityDescription, SensorEntityDescription): """Class to describe a RPC sensor.""" device_class_fn: Callable[[dict], SensorDeviceClass | None] | None = None + emeter_phase: str | None = None @dataclass(frozen=True, kw_only=True) @@ -119,6 +123,26 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): return self.option_map[attribute_value] +class RpcEmeterPhaseSensor(RpcSensor): + """Represent a RPC energy meter phase sensor.""" + + entity_description: RpcSensorDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcSensorDescription, + ) -> None: + """Initialize select.""" + super().__init__(coordinator, key, attribute, description) + + self._attr_device_info = get_rpc_device_info( + coordinator.device, coordinator.mac, key, description.emeter_phase + ) + + class RpcBluTrvSensor(RpcSensor): """Represent a RPC BluTrv sensor.""" @@ -133,8 +157,8 @@ class RpcBluTrvSensor(RpcSensor): super().__init__(coordinator, key, attribute, description) ble_addr: str = coordinator.device.config[key]["addr"] - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_BLUETOOTH, ble_addr)} + self._attr_device_info = get_blu_trv_device_info( + coordinator.device.config[key], ble_addr, coordinator.mac ) @@ -505,26 +529,32 @@ RPC_SENSORS: Final = { "a_act_power": RpcSensorDescription( key="em", sub_key="a_act_power", - name="Phase A active power", + name="Active power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + emeter_phase="A", + entity_class=RpcEmeterPhaseSensor, ), "b_act_power": RpcSensorDescription( key="em", sub_key="b_act_power", - name="Phase B active power", + name="Active power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + emeter_phase="B", + entity_class=RpcEmeterPhaseSensor, ), "c_act_power": RpcSensorDescription( key="em", sub_key="c_act_power", - name="Phase C active power", + name="Active power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + emeter_phase="C", + entity_class=RpcEmeterPhaseSensor, ), "total_act_power": RpcSensorDescription( key="em", @@ -537,26 +567,32 @@ RPC_SENSORS: Final = { "a_aprt_power": RpcSensorDescription( key="em", sub_key="a_aprt_power", - name="Phase A apparent power", + name="Apparent power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, + emeter_phase="A", + entity_class=RpcEmeterPhaseSensor, ), "b_aprt_power": RpcSensorDescription( key="em", sub_key="b_aprt_power", - name="Phase B apparent power", + name="Apparent power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, + emeter_phase="B", + entity_class=RpcEmeterPhaseSensor, ), "c_aprt_power": RpcSensorDescription( key="em", sub_key="c_aprt_power", - name="Phase C apparent power", + name="Apparent power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, + emeter_phase="C", + entity_class=RpcEmeterPhaseSensor, ), "aprt_power_em1": RpcSensorDescription( key="em1", @@ -584,23 +620,29 @@ RPC_SENSORS: Final = { "a_pf": RpcSensorDescription( key="em", sub_key="a_pf", - name="Phase A power factor", + name="Power factor", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, + emeter_phase="A", + entity_class=RpcEmeterPhaseSensor, ), "b_pf": RpcSensorDescription( key="em", sub_key="b_pf", - name="Phase B power factor", + name="Power factor", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, + emeter_phase="B", + entity_class=RpcEmeterPhaseSensor, ), "c_pf": RpcSensorDescription( key="em", sub_key="c_pf", - name="Phase C power factor", + name="Power factor", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, + emeter_phase="C", + entity_class=RpcEmeterPhaseSensor, ), "voltage": RpcSensorDescription( key="switch", @@ -682,29 +724,35 @@ RPC_SENSORS: Final = { "a_voltage": RpcSensorDescription( key="em", sub_key="a_voltage", - name="Phase A voltage", + name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + emeter_phase="A", + entity_class=RpcEmeterPhaseSensor, ), "b_voltage": RpcSensorDescription( key="em", sub_key="b_voltage", - name="Phase B voltage", + name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + emeter_phase="B", + entity_class=RpcEmeterPhaseSensor, ), "c_voltage": RpcSensorDescription( key="em", sub_key="c_voltage", - name="Phase C voltage", + name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + emeter_phase="C", + entity_class=RpcEmeterPhaseSensor, ), "current": RpcSensorDescription( key="switch", @@ -779,29 +827,35 @@ RPC_SENSORS: Final = { "a_current": RpcSensorDescription( key="em", sub_key="a_current", - name="Phase A current", + name="Current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + emeter_phase="A", + entity_class=RpcEmeterPhaseSensor, ), "b_current": RpcSensorDescription( key="em", sub_key="b_current", - name="Phase B current", + name="Current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + emeter_phase="B", + entity_class=RpcEmeterPhaseSensor, ), "c_current": RpcSensorDescription( key="em", sub_key="c_current", - name="Phase C current", + name="Current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + emeter_phase="C", + entity_class=RpcEmeterPhaseSensor, ), "n_current": RpcSensorDescription( key="em", @@ -834,6 +888,21 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "ret_energy": RpcSensorDescription( + key="switch", + sub_key="ret_aenergy", + name="Returned energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + removal_condition=lambda _config, status, key: ( + status[key].get("ret_aenergy") is None + ), + ), "energy_light": RpcSensorDescription( key="light", sub_key="aenergy", @@ -927,7 +996,7 @@ RPC_SENSORS: Final = { "a_total_act_energy": RpcSensorDescription( key="emdata", sub_key="a_total_act_energy", - name="Phase A total active energy", + name="Total active energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: float(status), @@ -935,11 +1004,13 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + emeter_phase="A", + entity_class=RpcEmeterPhaseSensor, ), "b_total_act_energy": RpcSensorDescription( key="emdata", sub_key="b_total_act_energy", - name="Phase B total active energy", + name="Total active energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: float(status), @@ -947,11 +1018,13 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + emeter_phase="B", + entity_class=RpcEmeterPhaseSensor, ), "c_total_act_energy": RpcSensorDescription( key="emdata", sub_key="c_total_act_energy", - name="Phase C total active energy", + name="Total active energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: float(status), @@ -959,6 +1032,8 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + emeter_phase="C", + entity_class=RpcEmeterPhaseSensor, ), "total_act_ret": RpcSensorDescription( key="emdata", @@ -986,7 +1061,7 @@ RPC_SENSORS: Final = { "a_total_act_ret_energy": RpcSensorDescription( key="emdata", sub_key="a_total_act_ret_energy", - name="Phase A total active returned energy", + name="Total active returned energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: float(status), @@ -994,11 +1069,13 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + emeter_phase="A", + entity_class=RpcEmeterPhaseSensor, ), "b_total_act_ret_energy": RpcSensorDescription( key="emdata", sub_key="b_total_act_ret_energy", - name="Phase B total active returned energy", + name="Total active returned energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: float(status), @@ -1006,11 +1083,13 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + emeter_phase="B", + entity_class=RpcEmeterPhaseSensor, ), "c_total_act_ret_energy": RpcSensorDescription( key="emdata", sub_key="c_total_act_ret_energy", - name="Phase C total active returned energy", + name="Total active returned energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: float(status), @@ -1018,6 +1097,8 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + emeter_phase="C", + entity_class=RpcEmeterPhaseSensor, ), "freq": RpcSensorDescription( key="switch", @@ -1052,32 +1133,38 @@ RPC_SENSORS: Final = { "a_freq": RpcSensorDescription( key="em", sub_key="a_freq", - name="Phase A frequency", + name="Frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, suggested_display_precision=0, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + emeter_phase="A", + entity_class=RpcEmeterPhaseSensor, ), "b_freq": RpcSensorDescription( key="em", sub_key="b_freq", - name="Phase B frequency", + name="Frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, suggested_display_precision=0, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + emeter_phase="B", + entity_class=RpcEmeterPhaseSensor, ), "c_freq": RpcSensorDescription( key="em", sub_key="c_freq", - name="Phase C frequency", + name="Frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, suggested_display_precision=0, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + emeter_phase="C", + entity_class=RpcEmeterPhaseSensor, ), "illuminance": RpcSensorDescription( key="illuminance", @@ -1090,7 +1177,7 @@ RPC_SENSORS: Final = { "temperature": RpcSensorDescription( key="switch", sub_key="temperature", - name="Device temperature", + name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value=lambda status, _: status["tC"], suggested_display_precision=1, @@ -1103,7 +1190,7 @@ RPC_SENSORS: Final = { "temperature_light": RpcSensorDescription( key="light", sub_key="temperature", - name="Device temperature", + name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value=lambda status, _: status["tC"], suggested_display_precision=1, @@ -1116,7 +1203,7 @@ RPC_SENSORS: Final = { "temperature_cct": RpcSensorDescription( key="cct", sub_key="temperature", - name="Device temperature", + name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value=lambda status, _: status["tC"], suggested_display_precision=1, @@ -1129,7 +1216,7 @@ RPC_SENSORS: Final = { "temperature_rgb": RpcSensorDescription( key="rgb", sub_key="temperature", - name="Device temperature", + name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value=lambda status, _: status["tC"], suggested_display_precision=1, @@ -1142,7 +1229,7 @@ RPC_SENSORS: Final = { "temperature_rgbw": RpcSensorDescription( key="rgbw", sub_key="temperature", - name="Device temperature", + name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value=lambda status, _: status["tC"], suggested_display_precision=1, @@ -1291,12 +1378,10 @@ RPC_SENSORS: Final = { "text": RpcSensorDescription( key="text", sub_key="value", - has_entity_name=True, ), "number": RpcSensorDescription( key="number", sub_key="value", - has_entity_name=True, unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] else None, @@ -1307,7 +1392,6 @@ RPC_SENSORS: Final = { "enum": RpcSensorDescription( key="enum", sub_key="value", - has_entity_name=True, options_fn=lambda config: config["options"], device_class=SensorDeviceClass.ENUM, ), diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index bc6f44a971b..28f3a993462 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -34,7 +34,7 @@ } }, "confirm_discovery": { - "description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device." + "description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password-protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password-protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device." }, "reconfigure": { "description": "Update configuration for {device_name}.\n\nBefore setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index ce9e4f065fb..1c184d260f8 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -39,6 +39,8 @@ from .utils import ( is_rpc_exclude_from_relay, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): @@ -289,7 +291,6 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): """Entity that controls a switch on RPC based Shelly devices.""" entity_description: RpcSwitchDescription - _attr_has_entity_name = True @property def is_on(self) -> bool: @@ -314,9 +315,6 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): class RpcRelaySwitch(RpcSwitch): """Entity that controls a switch on RPC based Shelly devices.""" - # False to avoid double naming as True is inerithed from base class - _attr_has_entity_name = False - def __init__( self, coordinator: ShellyRpcCoordinator, diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index 811467f9e43..d89531e2338 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -28,6 +28,8 @@ from .utils import ( get_virtual_component_ids, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RpcTextDescription(RpcEntityDescription, TextEntityDescription): @@ -38,7 +40,6 @@ RPC_TEXT_ENTITIES: Final = { "text": RpcTextDescription( key="text", sub_key="value", - has_entity_name=True, ), } diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 12ce6dc70cd..2ff2462bd79 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -47,6 +47,8 @@ from .utils import get_device_entry_gen, get_release_url LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RpcUpdateDescription(RpcEntityDescription, UpdateEntityDescription): diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 0c8048d34e4..cc0f2cf75d5 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -11,9 +11,12 @@ from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import COAP, Block, BlockDevice from aioshelly.const import ( BLOCK_GENERATIONS, + BLU_TRV_IDENTIFIER, + BLU_TRV_MODEL_NAME, DEFAULT_COAP_PORT, DEFAULT_HTTP_PORT, MODEL_1L, + MODEL_BLU_GATEWAY_G3, MODEL_DIMMER, MODEL_DIMMER_2, MODEL_EM3, @@ -40,7 +43,11 @@ from homeassistant.helpers import ( issue_registry as ir, singleton, ) -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.util.dt import utcnow @@ -65,7 +72,9 @@ from .const import ( SHELLY_EMIT_EVENT_PATTERN, SHIX3_1_INPUTS_EVENTS_TYPES, UPTIME_DEVIATION, + VIRTUAL_COMPONENTS, VIRTUAL_COMPONENTS_MAP, + All_LIGHT_TYPES, ) @@ -109,26 +118,24 @@ def get_block_entity_name( device: BlockDevice, block: Block | None, description: str | None = None, -) -> str: +) -> str | None: """Naming for block based switch and sensors.""" channel_name = get_block_channel_name(device, block) if description: - return f"{channel_name} {description.lower()}" + return f"{channel_name} {description.lower()}" if channel_name else description return channel_name -def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: +def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | None: """Get name based on device and channel name.""" - entity_name = device.name - if ( not block - or block.type == "device" + or block.type in ("device", "light", "relay", "emeter") or get_number_of_channels(device, block) == 1 ): - return entity_name + return None assert block.channel @@ -140,12 +147,28 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: if channel_name: return channel_name + base = ord("1") + + return f"Channel {chr(int(block.channel) + base)}" + + +def get_block_sub_device_name(device: BlockDevice, block: Block) -> str: + """Get name of block sub-device.""" + if TYPE_CHECKING: + assert block.channel + + mode = cast(str, block.type) + "s" + if mode in device.settings: + if channel_name := device.settings[mode][int(block.channel)].get("name"): + return cast(str, channel_name) + if device.settings["device"]["type"] == MODEL_EM3: base = ord("A") - else: - base = ord("1") + return f"{device.name} Phase {chr(int(block.channel) + base)}" - return f"{entity_name} channel {chr(int(block.channel) + base)}" + base = ord("1") + + return f"{device.name} Channel {chr(int(block.channel) + base)}" def is_block_momentary_input( @@ -364,39 +387,64 @@ def get_shelly_model_name( return cast(str, MODEL_NAMES.get(model)) -def get_rpc_channel_name(device: RpcDevice, key: str) -> str: +def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None: """Get name based on device and channel name.""" + if BLU_TRV_IDENTIFIER in key: + return None + + instances = len( + get_rpc_key_instances(device.status, key.split(":")[0], all_lights=True) + ) + component = key.split(":")[0] + component_id = key.split(":")[-1] + + if key in device.config and key != "em:0": + # workaround for Pro 3EM, we don't want to get name for em:0 + if component_name := device.config[key].get("name"): + if component in (*VIRTUAL_COMPONENTS, "script"): + return cast(str, component_name) + + return cast(str, component_name) if instances == 1 else None + + if component in VIRTUAL_COMPONENTS: + return f"{component.title()} {component_id}" + + return None + + +def get_rpc_sub_device_name( + device: RpcDevice, key: str, emeter_phase: str | None = None +) -> str: + """Get name based on device and channel name.""" + if key in device.config and key != "em:0": + # workaround for Pro 3EM, we don't want to get name for em:0 + if entity_name := device.config[key].get("name"): + return cast(str, entity_name) + key = key.replace("emdata", "em") key = key.replace("em1data", "em1") - device_name = device.name - entity_name: str | None = None - if key in device.config: - entity_name = device.config[key].get("name") - if entity_name is None: - channel = key.split(":")[0] - channel_id = key.split(":")[-1] - if key.startswith(("cover:", "input:", "light:", "switch:", "thermostat:")): - return f"{device_name} {channel.title()} {channel_id}" - if key.startswith(("cct", "rgb:", "rgbw:")): - return f"{device_name} {channel.upper()} light {channel_id}" - if key.startswith("em1"): - return f"{device_name} EM{channel_id}" - if key.startswith(("boolean:", "enum:", "number:", "text:")): - return f"{channel.title()} {channel_id}" - return device_name + component = key.split(":")[0] + component_id = key.split(":")[-1] - return entity_name + if component in ("cct", "rgb", "rgbw"): + return f"{device.name} {component.upper()} light {component_id}" + if component == "em1": + return f"{device.name} Energy Meter {component_id}" + if component == "em" and emeter_phase is not None: + return f"{device.name} Phase {emeter_phase}" + + return f"{device.name} {component.title()} {component_id}" def get_rpc_entity_name( device: RpcDevice, key: str, description: str | None = None -) -> str: +) -> str | None: """Naming for RPC based switch and sensors.""" channel_name = get_rpc_channel_name(device, key) if description: - return f"{channel_name} {description.lower()}" + return f"{channel_name} {description.lower()}" if channel_name else description return channel_name @@ -406,7 +454,9 @@ def get_device_entry_gen(entry: ConfigEntry) -> int: return entry.data.get(CONF_GEN, 1) -def get_rpc_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]: +def get_rpc_key_instances( + keys_dict: dict[str, Any], key: str, all_lights: bool = False +) -> list[str]: """Return list of key instances for RPC device from a dict.""" if key in keys_dict: return [key] @@ -414,6 +464,9 @@ def get_rpc_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]: if key == "switch" and "cover:0" in keys_dict: key = "cover" + if key in All_LIGHT_TYPES and all_lights: + return [k for k in keys_dict if k.startswith(All_LIGHT_TYPES)] + return [k for k in keys_dict if k.startswith(f"{key}:")] @@ -691,3 +744,110 @@ async def get_rpc_scripts_event_types( script_events[script_id] = await get_rpc_script_event_types(device, script_id) return script_events + + +def get_rpc_device_info( + device: RpcDevice, + mac: str, + key: str | None = None, + emeter_phase: str | None = None, +) -> DeviceInfo: + """Return device info for RPC device.""" + if key is None: + return DeviceInfo(connections={(CONNECTION_NETWORK_MAC, mac)}) + + # workaround for Pro EM50 + key = key.replace("em1data", "em1") + # workaround for Pro 3EM + key = key.replace("emdata", "em") + + key_parts = key.split(":") + component = key_parts[0] + idx = key_parts[1] if len(key_parts) > 1 else None + + if emeter_phase is not None: + return DeviceInfo( + identifiers={(DOMAIN, f"{mac}-{key}-{emeter_phase.lower()}")}, + name=get_rpc_sub_device_name(device, key, emeter_phase), + manufacturer="Shelly", + via_device=(DOMAIN, mac), + ) + + if ( + component not in (*All_LIGHT_TYPES, "cover", "em1", "switch") + or idx is None + or len(get_rpc_key_instances(device.status, component, all_lights=True)) < 2 + ): + return DeviceInfo(connections={(CONNECTION_NETWORK_MAC, mac)}) + + return DeviceInfo( + identifiers={(DOMAIN, f"{mac}-{key}")}, + name=get_rpc_sub_device_name(device, key), + manufacturer="Shelly", + via_device=(DOMAIN, mac), + ) + + +def get_blu_trv_device_info( + config: dict[str, Any], ble_addr: str, parent_mac: str +) -> DeviceInfo: + """Return device info for RPC device.""" + model_id = config.get("local_name") + return DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)}, + identifiers={(DOMAIN, ble_addr)}, + via_device=(DOMAIN, parent_mac), + manufacturer="Shelly", + model=BLU_TRV_MODEL_NAME.get(model_id) if model_id else None, + model_id=config.get("local_name"), + name=config["name"] or f"shellyblutrv-{ble_addr.replace(':', '')}", + ) + + +def get_block_device_info( + device: BlockDevice, mac: str, block: Block | None = None +) -> DeviceInfo: + """Return device info for Block device.""" + if ( + block is None + or block.type not in ("light", "relay", "emeter") + or device.settings.get("mode") == "roller" + or get_number_of_channels(device, block) < 2 + ): + return DeviceInfo(connections={(CONNECTION_NETWORK_MAC, mac)}) + + return DeviceInfo( + identifiers={(DOMAIN, f"{mac}-{block.description}")}, + name=get_block_sub_device_name(device, block), + manufacturer="Shelly", + via_device=(DOMAIN, mac), + ) + + +@callback +def remove_stale_blu_trv_devices( + hass: HomeAssistant, rpc_device: RpcDevice, entry: ConfigEntry +) -> None: + """Remove stale BLU TRV devices.""" + if rpc_device.model != MODEL_BLU_GATEWAY_G3: + return + + dev_reg = dr.async_get(hass) + devices = dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id) + config = rpc_device.config + blutrv_keys = get_rpc_key_ids(config, BLU_TRV_IDENTIFIER) + trv_addrs = [config[f"{BLU_TRV_IDENTIFIER}:{key}"]["addr"] for key in blutrv_keys] + + for device in devices: + if not device.via_device_id: + # Device is not a sub-device, skip + continue + + if any( + identifier[0] == DOMAIN and identifier[1] in trv_addrs + for identifier in device.identifiers + ): + continue + + LOGGER.debug("Removing stale BLU TRV device %s", device.name) + dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id) diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py index 1829f663b22..b748172ba3d 100644 --- a/homeassistant/components/shelly/valve.py +++ b/homeassistant/components/shelly/valve.py @@ -25,6 +25,8 @@ from .entity import ( ) from .utils import async_remove_shelly_entity, get_device_entry_gen +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class BlockValveDescription(BlockEntityDescription, ValveEntityDescription): diff --git a/homeassistant/components/sia/strings.json b/homeassistant/components/sia/strings.json index e5eb4770db5..df2e11b5659 100644 --- a/homeassistant/components/sia/strings.json +++ b/homeassistant/components/sia/strings.json @@ -6,12 +6,12 @@ "port": "[%key:common::config_flow::data::port%]", "protocol": "Protocol", "account": "Account ID", - "encryption_key": "Encryption Key", - "ping_interval": "Ping Interval (min)", + "encryption_key": "Encryption key", + "ping_interval": "Ping interval (min)", "zones": "Number of zones for the account", "additional_account": "Additional accounts" }, - "title": "Create a connection for SIA based alarm systems." + "title": "Create a connection for SIA-based alarm systems." }, "additional_account": { "data": { diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index 222b61456c4..9636192f6e1 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -5,6 +5,7 @@ from __future__ import annotations import io import logging from pathlib import Path +from typing import TYPE_CHECKING, Any from PIL import Image, ImageDraw, UnidentifiedImageError import simplehound.core as hound @@ -59,8 +60,8 @@ def setup_platform( ) -> None: """Set up the platform.""" # Validate credentials by processing image. - api_key = config[CONF_API_KEY] - account_type = config[CONF_ACCOUNT_TYPE] + api_key: str = config[CONF_API_KEY] + account_type: str = config[CONF_ACCOUNT_TYPE] api = hound.cloud(api_key, account_type) try: api.detect(b"Test") @@ -72,7 +73,8 @@ def setup_platform( save_file_folder = Path(save_file_folder) entities = [] - for camera in config[CONF_SOURCE]: + source: list[dict[str, str]] = config[CONF_SOURCE] + for camera in source: sighthound = SighthoundEntity( api, camera[CONF_ENTITY_ID], @@ -91,29 +93,34 @@ class SighthoundEntity(ImageProcessingEntity): _attr_unit_of_measurement = ATTR_PEOPLE def __init__( - self, api, camera_entity, name, save_file_folder, save_timestamped_file - ): + self, + api: hound.cloud, + camera_entity: str, + name: str | None, + save_file_folder: Path | None, + save_timestamped_file: bool, + ) -> None: """Init.""" self._api = api - self._camera = camera_entity + self._attr_camera_entity = camera_entity if name: - self._name = name + self._attr_name = name else: camera_name = split_entity_id(camera_entity)[1] - self._name = f"sighthound_{camera_name}" - self._state = None - self._last_detection = None - self._image_width = None - self._image_height = None + self._attr_name = f"sighthound_{camera_name}" + self._attr_state = None + self._last_detection: str | None = None + self._image_width: int | None = None + self._image_height: int | None = None self._save_file_folder = save_file_folder self._save_timestamped_file = save_timestamped_file - def process_image(self, image): + def process_image(self, image: bytes) -> None: """Process an image.""" detections = self._api.detect(image) people = hound.get_people(detections) - self._state = len(people) - if self._state > 0: + self._attr_state = len(people) + if self._attr_state > 0: self._last_detection = dt_util.now().strftime(DATETIME_FORMAT) metadata = hound.get_metadata(detections) @@ -121,10 +128,10 @@ class SighthoundEntity(ImageProcessingEntity): self._image_height = metadata["image_height"] for person in people: self.fire_person_detected_event(person) - if self._save_file_folder and self._state > 0: + if self._save_file_folder and self._attr_state > 0: self.save_image(image, people, self._save_file_folder) - def fire_person_detected_event(self, person): + def fire_person_detected_event(self, person: dict[str, Any]) -> None: """Send event with detected total_persons.""" self.hass.bus.fire( EVENT_PERSON_DETECTED, @@ -136,7 +143,9 @@ class SighthoundEntity(ImageProcessingEntity): }, ) - def save_image(self, image, people, directory): + def save_image( + self, image: bytes, people: list[dict[str, Any]], directory: Path + ) -> None: """Save a timestamped image with bounding boxes around targets.""" try: img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") @@ -145,37 +154,26 @@ class SighthoundEntity(ImageProcessingEntity): return draw = ImageDraw.Draw(img) + if TYPE_CHECKING: + assert self._image_width is not None + assert self._image_height is not None + for person in people: box = hound.bbox_to_tf_style( person["boundingBox"], self._image_width, self._image_height ) draw_box(draw, box, self._image_width, self._image_height) - latest_save_path = directory / f"{self._name}_latest.jpg" + latest_save_path = directory / f"{self.name}_latest.jpg" img.save(latest_save_path) if self._save_timestamped_file: - timestamp_save_path = directory / f"{self._name}_{self._last_detection}.jpg" + timestamp_save_path = directory / f"{self.name}_{self._last_detection}.jpg" img.save(timestamp_save_path) _LOGGER.debug("Sighthound saved file %s", timestamp_save_path) @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the entity.""" - return self._state - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the attributes.""" if not self._last_detection: return {} diff --git a/homeassistant/components/simplefin/strings.json b/homeassistant/components/simplefin/strings.json index 3ac03fe2cc0..b3750a96b1e 100644 --- a/homeassistant/components/simplefin/strings.json +++ b/homeassistant/components/simplefin/strings.json @@ -2,22 +2,22 @@ "config": { "step": { "user": { - "description": "Please enter either a Claim Token or an Access URL.", + "description": "Please enter a SimpleFIN setup token.", "data": { - "api_token": "Claim Token or Access URL" + "api_token": "Setup token" } } }, "error": { "invalid_auth": "Authentication failed: This could be due to revoked access or incorrect credentials", - "claim_error": "The claim token either does not exist or has already been used claimed by someone else. Receiving this could mean that the user\u2019s transaction information has been compromised", - "invalid_claim_token": "The claim token is invalid and could not be decoded", - "payment_required": "You presented a valid access url, however payment is required before you can obtain data", - "url_error": "There was an issue parsing the Account URL" + "claim_error": "The setup token either does not exist or has already been used by someone else. Receiving this could mean that the user\u2019s transaction information has been compromised", + "invalid_claim_token": "The setup token is invalid and could not be decoded", + "payment_required": "You presented a valid access URL, however payment is required before you can obtain data", + "url_error": "There was an issue parsing the access URL" }, "abort": { - "missing_access_url": "Access URL or Claim Token missing", - "already_configured": "This Access URL is already configured." + "missing_access_url": "Access URL or setup token missing", + "already_configured": "This access URL is already configured." } }, "entity": { diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 27fa54e46dd..0dc8fb83fac 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) 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 homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo @@ -63,6 +63,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: pysma.exceptions.SmaConnectionException, ) as exc: raise ConfigEntryNotReady from exc + except pysma.exceptions.SmaAuthenticationException as exc: + raise ConfigEntryAuthFailed from exc if TYPE_CHECKING: assert entry.unique_id diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 3210d904b6b..f43c851d04a 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -137,6 +138,42 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth on credential failure.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare reauth.""" + errors: dict[str, str] = {} + if user_input is not None: + reauth_entry = self._get_reauth_entry() + errors, device_info = await self._handle_user_input( + user_input={ + **reauth_entry.data, + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) + + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + ) + async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: @@ -181,5 +218,6 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required(CONF_PASSWORD): cv.string, } ), + description_placeholders={CONF_HOST: self._data[CONF_HOST]}, errors=errors, ) diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index 16e5d7408c4..8253d94a749 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -11,6 +12,13 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The SMA integration needs to re-authenticate your connection details", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "data": { "group": "Group", @@ -24,6 +32,16 @@ }, "description": "Enter your SMA device information.", "title": "Set up SMA Solar" + }, + "discovery_confirm": { + "title": "[%key:component::sma::config::step::user::title%]", + "description": "Do you want to set up the discovered SMA device ({host})?", + "data": { + "group": "[%key:component::sma::config::step::user::data::group%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } } } } diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py new file mode 100644 index 00000000000..c55b1067735 --- /dev/null +++ b/homeassistant/components/smarla/__init__.py @@ -0,0 +1,39 @@ +"""The Swing2Sleep Smarla integration.""" + +from pysmarlaapi import Connection, Federwiege + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .const import HOST, PLATFORMS + +type FederwiegeConfigEntry = ConfigEntry[Federwiege] + + +async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -> bool: + """Set up this integration using UI.""" + connection = Connection(HOST, token_b64=entry.data[CONF_ACCESS_TOKEN]) + + # Check if token still has access + if not await connection.refresh_token(): + raise ConfigEntryAuthFailed("Invalid authentication") + + federwiege = Federwiege(hass.loop, connection) + federwiege.register() + federwiege.connect() + + entry.runtime_data = federwiege + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry.runtime_data.disconnect() + + return unload_ok diff --git a/homeassistant/components/smarla/config_flow.py b/homeassistant/components/smarla/config_flow.py new file mode 100644 index 00000000000..816adc85d1a --- /dev/null +++ b/homeassistant/components/smarla/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Swing2Sleep Smarla integration.""" + +from __future__ import annotations + +from typing import Any + +from pysmarlaapi import Connection +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN + +from .const import DOMAIN, HOST + +STEP_USER_DATA_SCHEMA = vol.Schema({CONF_ACCESS_TOKEN: str}) + + +class SmarlaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Swing2Sleep Smarla.""" + + VERSION = 1 + + async def _handle_token(self, token: str) -> tuple[dict[str, str], str | None]: + """Handle the token input.""" + errors: dict[str, str] = {} + + try: + conn = Connection(url=HOST, token_b64=token) + except ValueError: + errors["base"] = "malformed_token" + return errors, None + + if not await conn.refresh_token(): + errors["base"] = "invalid_auth" + return errors, None + + return errors, conn.token.serialNumber + + 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: + raw_token = user_input[CONF_ACCESS_TOKEN] + errors, serial_number = await self._handle_token(token=raw_token) + + if not errors and serial_number is not None: + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=serial_number, + data={CONF_ACCESS_TOKEN: raw_token}, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/smarla/const.py b/homeassistant/components/smarla/const.py new file mode 100644 index 00000000000..7125e3f7270 --- /dev/null +++ b/homeassistant/components/smarla/const.py @@ -0,0 +1,12 @@ +"""Constants for the Swing2Sleep Smarla integration.""" + +from homeassistant.const import Platform + +DOMAIN = "smarla" + +HOST = "https://devices.swing2sleep.de" + +PLATFORMS = [Platform.SWITCH] + +DEVICE_MODEL_NAME = "Smarla" +MANUFACTURER_NAME = "Swing2Sleep" diff --git a/homeassistant/components/smarla/entity.py b/homeassistant/components/smarla/entity.py new file mode 100644 index 00000000000..a0ca052219c --- /dev/null +++ b/homeassistant/components/smarla/entity.py @@ -0,0 +1,41 @@ +"""Common base for entities.""" + +from typing import Any + +from pysmarlaapi import Federwiege +from pysmarlaapi.federwiege.classes import Property + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DEVICE_MODEL_NAME, DOMAIN, MANUFACTURER_NAME + + +class SmarlaBaseEntity(Entity): + """Common Base Entity class for defining Smarla device.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, federwiege: Federwiege, prop: Property) -> None: + """Initialise the entity.""" + self._property = prop + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, federwiege.serial_number)}, + name=DEVICE_MODEL_NAME, + model=DEVICE_MODEL_NAME, + manufacturer=MANUFACTURER_NAME, + serial_number=federwiege.serial_number, + ) + + async def on_change(self, value: Any): + """Notify ha when state changes.""" + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when this Entity has been added to HA.""" + await self._property.add_listener(self.on_change) + + async def async_will_remove_from_hass(self) -> None: + """Entity being removed from hass.""" + await self._property.remove_listener(self.on_change) diff --git a/homeassistant/components/smarla/icons.json b/homeassistant/components/smarla/icons.json new file mode 100644 index 00000000000..5a31ec88822 --- /dev/null +++ b/homeassistant/components/smarla/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "switch": { + "smart_mode": { + "default": "mdi:refresh-auto" + } + } + } +} diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json new file mode 100644 index 00000000000..5e572c78536 --- /dev/null +++ b/homeassistant/components/smarla/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "smarla", + "name": "Swing2Sleep Smarla", + "codeowners": ["@explicatis", "@rlint-explicatis"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/smarla", + "integration_type": "device", + "iot_class": "cloud_push", + "loggers": ["pysmarlaapi", "pysignalr"], + "quality_scale": "bronze", + "requirements": ["pysmarlaapi==0.8.2"] +} diff --git a/homeassistant/components/smarla/quality_scale.yaml b/homeassistant/components/smarla/quality_scale.yaml new file mode 100644 index 00000000000..99b6e0c608c --- /dev/null +++ b/homeassistant/components/smarla/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + 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: todo + discovery: todo + 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: todo + 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/smarla/strings.json b/homeassistant/components/smarla/strings.json new file mode 100644 index 00000000000..8426bc30566 --- /dev/null +++ b/homeassistant/components/smarla/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "malformed_token": "Malformed access token" + }, + "step": { + "user": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "access_token": "The access token generated by the Swing2Sleep app." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "switch": { + "smart_mode": { + "name": "Smart Mode" + } + } + } +} diff --git a/homeassistant/components/smarla/switch.py b/homeassistant/components/smarla/switch.py new file mode 100644 index 00000000000..49bcce23b24 --- /dev/null +++ b/homeassistant/components/smarla/switch.py @@ -0,0 +1,80 @@ +"""Support for the Swing2Sleep Smarla switch entities.""" + +from dataclasses import dataclass +from typing import Any + +from pysmarlaapi import Federwiege +from pysmarlaapi.federwiege.classes import Property + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FederwiegeConfigEntry +from .entity import SmarlaBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class SmarlaSwitchEntityDescription(SwitchEntityDescription): + """Class describing Swing2Sleep Smarla switch entity.""" + + service: str + property: str + + +SWITCHES: list[SmarlaSwitchEntityDescription] = [ + SmarlaSwitchEntityDescription( + key="swing_active", + name=None, + service="babywiege", + property="swing_active", + ), + SmarlaSwitchEntityDescription( + key="smart_mode", + translation_key="smart_mode", + service="babywiege", + property="smart_mode", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FederwiegeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Smarla switches from config entry.""" + federwiege = config_entry.runtime_data + async_add_entities(SmarlaSwitch(federwiege, desc) for desc in SWITCHES) + + +class SmarlaSwitch(SmarlaBaseEntity, SwitchEntity): + """Representation of Smarla switch.""" + + entity_description: SmarlaSwitchEntityDescription + + _property: Property[bool] + + def __init__( + self, + federwiege: Federwiege, + desc: SmarlaSwitchEntityDescription, + ) -> None: + """Initialize a Smarla switch.""" + prop = federwiege.get_property(desc.service, desc.property) + super().__init__(federwiege, prop) + self.entity_description = desc + self._attr_unique_id = f"{federwiege.serial_number}-{desc.key}" + + @property + def is_on(self) -> bool: + """Return the entity value to represent the entity state.""" + return self._property.get() + + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._property.set(True) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._property.set(False) diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index c6e18bf43c1..480188ab2a6 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -74,7 +74,7 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): self._attr_native_value = self.meter.reading self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to updates.""" await super().async_added_to_hass() self.async_on_remove(self.coordinator.async_add_listener(self._state_update)) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 557d14f8a64..e4259e4182c 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -104,6 +104,7 @@ PLATFORMS = [ Platform.SWITCH, Platform.UPDATE, Platform.VALVE, + Platform.WATER_HEATER, ] @@ -288,7 +289,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for identifier in device_entry.identifiers if identifier[0] == DOMAIN ) - if device_id in device_status: + if any( + device_id.startswith(device_identifier) + for device_identifier in device_status + ): continue device_registry.async_update_device( device_entry.id, remove_config_entry_id=entry.entry_id @@ -515,6 +519,11 @@ def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentSta ) if disabled_components is not None: for component in disabled_components: + # Burner components are named burner-06 + # but disabledComponents contain burner-6 + if "burner" in component: + burner_id = int(component.split("-")[-1]) + component = f"burner-0{burner_id}" if component in status: del status[component] for component_status in status.values(): diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 74d561f08ac..ea8db71c481 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -80,6 +80,14 @@ CAPABILITY_TO_SENSORS: dict[ entity_category=EntityCategory.DIAGNOSTIC, ) }, + Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE: { + Attribute.OPERATING_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.OPERATING_STATE, + translation_key="keep_fresh_mode_active", + is_on_key="running", + entity_category=EntityCategory.DIAGNOSTIC, + ) + }, Capability.FILTER_STATUS: { Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription( key=Attribute.FILTER_STATUS, diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 9de11f4af71..f87c9bbfcef 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -12,6 +12,8 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -23,10 +25,11 @@ 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_platform import AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import DOMAIN, MAIN, UNIT_MAP from .entity import SmartThingsEntity ATTR_OPERATION_STATE = "operation_state" @@ -88,11 +91,18 @@ FAN_OSCILLATION_TO_SWING = { value: key for key, value in SWING_TO_FAN_OSCILLATION.items() } +HEAT_PUMP_AC_MODE_TO_HA = { + "auto": HVACMode.AUTO, + "cool": HVACMode.COOL, + "heat": HVACMode.HEAT, +} + +HA_MODE_TO_HEAT_PUMP_AC_MODE = {v: k for k, v in HEAT_PUMP_AC_MODE_TO_HA.items()} + WIND = "wind" FAN = "fan" WINDFREE = "windFree" -UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} _LOGGER = logging.getLogger(__name__) @@ -111,6 +121,14 @@ THERMOSTAT_CAPABILITIES = [ Capability.THERMOSTAT_MODE, ] +HEAT_PUMP_CAPABILITIES = [ + Capability.TEMPERATURE_MEASUREMENT, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Capability.AIR_CONDITIONER_MODE, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.SWITCH, +] + async def async_setup_entry( hass: HomeAssistant, @@ -131,6 +149,16 @@ async def async_setup_entry( capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES ) ) + entities.extend( + SmartThingsHeatPumpZone(entry_data.client, device, component) + for device in entry_data.devices.values() + for component in device.status + if component in {"INDOOR", "INDOOR1", "INDOOR2"} + and all( + capability in device.status[component] + for capability in HEAT_PUMP_CAPABILITIES + ) + ) async_add_entities(entities) @@ -309,7 +337,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): return None @property - def target_temperature_low(self): + def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self.get_attribute_value( @@ -593,3 +621,148 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): if state not in modes ) return modes + + +class SmartThingsHeatPumpZone(SmartThingsEntity, ClimateEntity): + """Define a SmartThings heat pump zone.""" + + _attr_name = None + + def __init__(self, client: SmartThings, device: FullDevice, component: str) -> None: + """Init the class.""" + super().__init__( + client, + device, + { + Capability.AIR_CONDITIONER_MODE, + Capability.SWITCH, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.TEMPERATURE_MEASUREMENT, + }, + component=component, + ) + self._attr_hvac_modes = self._determine_hvac_modes() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{device.device.device_id}_{component}")}, + via_device=(DOMAIN, device.device.device_id), + name=f"{device.device.label} {component}", + ) + + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + if ( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE + ) + != "auto" + ): + features |= ClimateEntityFeature.TARGET_TEMPERATURE + return features + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + min_setpoint = self.get_attribute_value( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, Attribute.MINIMUM_SETPOINT + ) + if min_setpoint == -1000: + return DEFAULT_MIN_TEMP + return min_setpoint + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + max_setpoint = self.get_attribute_value( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, Attribute.MAXIMUM_SETPOINT + ) + if max_setpoint == -1000: + return DEFAULT_MAX_TEMP + return max_setpoint + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target operation mode.""" + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + return + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": + await self.async_turn_on() + + await self.execute_device_command( + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + argument=HA_MODE_TO_HEAT_PUMP_AC_MODE[hvac_mode], + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + await self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=kwargs[ATTR_TEMPERATURE], + ) + + async def async_turn_on(self) -> None: + """Turn device on.""" + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) + + async def async_turn_off(self) -> None: + """Turn device off.""" + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) + + @property + def hvac_mode(self) -> HVACMode | None: + """Return current operation ie. heat, cool, idle.""" + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": + return HVACMode.OFF + return HEAT_PUMP_AC_MODE_TO_HA.get( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE + ) + ) + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit + return UNIT_MAP[unit] + + def _determine_hvac_modes(self) -> list[HVACMode]: + """Determine the supported HVAC modes.""" + modes = [HVACMode.OFF] + if ( + ac_modes := self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ) + ) is not None: + modes.extend( + state + for mode in ac_modes + if (state := HEAT_PUMP_AC_MODE_TO_HA.get(mode)) is not None + ) + return modes diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 8f27b785688..1925d973ef4 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -2,6 +2,8 @@ from pysmartthings import Attribute, Capability, Category +from homeassistant.const import UnitOfTemperature + DOMAIN = "smartthings" SCOPES = [ @@ -118,3 +120,5 @@ INVALID_SWITCH_CATEGORIES = { Category.MICROWAVE, Category.DISHWASHER, } + +UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 3125bd65548..668dff961ee 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -18,6 +18,9 @@ "state": { "on": "mdi:lock" } + }, + "keep_fresh_mode": { + "default": "mdi:creation" } }, "button": { @@ -31,6 +34,9 @@ "number": { "washer_rinse_cycles": { "default": "mdi:waves-arrow-up" + }, + "freezer_temperature": { + "default": "mdi:snowflake-thermometer" } }, "select": { @@ -41,11 +47,56 @@ "stop": "mdi:stop" } }, + "lamp": { + "default": "mdi:lightbulb", + "state": { + "on": "mdi:lightbulb-on", + "off": "mdi:lightbulb-off" + } + }, "detergent_amount": { "default": "mdi:car-coolant-level" }, "flexible_detergent_amount": { "default": "mdi:car-coolant-level" + }, + "spin_level": { + "default": "mdi:rotate-right" + } + }, + "sensor": { + "cooktop_operating_state": { + "default": "mdi:stove", + "state": { + "ready": "mdi:play-speed", + "run": "mdi:play", + "paused": "mdi:pause", + "finished": "mdi:food-turkey" + } + }, + "diverter_valve_position": { + "state": { + "room": "mdi:sofa", + "tank": "mdi:water-boiler" + } + }, + "manual_level": { + "default": "mdi:radiator", + "state": { + "0": "mdi:radiator-off" + } + }, + "heating_mode": { + "state": { + "off": "mdi:power", + "manual": "mdi:cog", + "boost": "mdi:flash", + "keep_warm": "mdi:fire", + "quick_preheat": "mdi:heat-wave", + "defrost": "mdi:car-defrost-rear", + "melt": "mdi:snowflake-melt", + "simmer": "mdi:fire" + } } }, "switch": { @@ -61,8 +112,26 @@ "off": "mdi:tumble-dryer-off" } }, + "keep_fresh_mode": { + "default": "mdi:creation" + }, "ice_maker": { "default": "mdi:delete-variant" + }, + "power_cool": { + "default": "mdi:snowflake-alert" + }, + "power_freeze": { + "default": "mdi:snowflake" + }, + "sanitize": { + "default": "mdi:lotion" + }, + "auto_cycle_link": { + "default": "mdi:link-off", + "state": { + "on": "mdi:link" + } } } } diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 180d4eebed1..481048c3bdb 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.2.3"] + "requirements": ["pysmartthings==3.2.4"] } diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py index 0a9b5dcb03f..6ac2f60d7a9 100644 --- a/homeassistant/components/smartthings/number.py +++ b/homeassistant/components/smartthings/number.py @@ -4,13 +4,13 @@ from __future__ import annotations from pysmartthings import Attribute, Capability, Command, SmartThings -from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import MAIN, UNIT_MAP from .entity import SmartThingsEntity @@ -21,11 +21,30 @@ async def async_setup_entry( ) -> None: """Add number entities for a config entry.""" entry_data = entry.runtime_data - async_add_entities( + entities: list[NumberEntity] = [ SmartThingsWasherRinseCyclesNumberEntity(entry_data.client, device) for device in entry_data.devices.values() if Capability.CUSTOM_WASHER_RINSE_CYCLES in device.status[MAIN] + ] + entities.extend( + SmartThingsHoodNumberEntity(entry_data.client, device) + for device in entry_data.devices.values() + if ( + (hood_component := device.status.get("hood")) is not None + and Capability.SAMSUNG_CE_HOOD_FAN_SPEED in hood_component + and Capability.SAMSUNG_CE_CONNECTION_STATE not in hood_component + ) ) + entities.extend( + SmartThingsRefrigeratorTemperatureNumberEntity( + entry_data.client, device, component + ) + for device in entry_data.devices.values() + for component in device.status + if component in ("cooler", "freezer") + and Capability.THERMOSTAT_COOLING_SETPOINT in device.status[component] + ) + async_add_entities(entities) class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): @@ -76,3 +95,125 @@ class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): Command.SET_WASHER_RINSE_CYCLES, str(int(value)), ) + + +class SmartThingsHoodNumberEntity(SmartThingsEntity, NumberEntity): + """Define a SmartThings number.""" + + _attr_translation_key = "hood_fan_speed" + _attr_native_step = 1.0 + _attr_mode = NumberMode.SLIDER + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the instance.""" + super().__init__( + client, device, {Capability.SAMSUNG_CE_HOOD_FAN_SPEED}, component="hood" + ) + self._attr_unique_id = f"{device.device.device_id}_hood_{Capability.SAMSUNG_CE_HOOD_FAN_SPEED}_{Attribute.HOOD_FAN_SPEED}_{Attribute.HOOD_FAN_SPEED}" + + @property + def options(self) -> list[int]: + """Return the list of options.""" + min_value = self.get_attribute_value( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Attribute.SETTABLE_MIN_FAN_SPEED, + ) + max_value = self.get_attribute_value( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Attribute.SETTABLE_MAX_FAN_SPEED, + ) + return list(range(min_value, max_value + 1)) + + @property + def native_value(self) -> int: + """Return the current value.""" + return int( + self.get_attribute_value( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, Attribute.HOOD_FAN_SPEED + ) + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return min(self.options) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return max(self.options) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Command.SET_HOOD_FAN_SPEED, + int(value), + ) + + +class SmartThingsRefrigeratorTemperatureNumberEntity(SmartThingsEntity, NumberEntity): + """Define a SmartThings number.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = NumberDeviceClass.TEMPERATURE + + def __init__(self, client: SmartThings, device: FullDevice, component: str) -> None: + """Initialize the instance.""" + super().__init__( + client, + device, + {Capability.THERMOSTAT_COOLING_SETPOINT}, + component=component, + ) + self._attr_unique_id = f"{device.device.device_id}_{component}_{Capability.THERMOSTAT_COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}" + unit = self._internal_state[Capability.THERMOSTAT_COOLING_SETPOINT][ + Attribute.COOLING_SETPOINT + ].unit + assert unit is not None + self._attr_native_unit_of_measurement = UNIT_MAP[unit] + self._attr_translation_key = { + "cooler": "cooler_temperature", + "freezer": "freezer_temperature", + }[component] + + @property + def range(self) -> dict[str, int]: + """Return the list of options.""" + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.COOLING_SETPOINT_RANGE, + ) + + @property + def native_value(self) -> int: + """Return the current value.""" + return int( + self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return self.range["minimum"] + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return self.range["maximum"] + + @property + def native_step(self) -> float: + """Return the step value.""" + return self.range["step"] + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + int(value), + ) diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index 16051cb08f1..99dc7a09f87 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -16,6 +16,41 @@ from . import FullDevice, SmartThingsConfigEntry from .const import MAIN from .entity import SmartThingsEntity +LAMP_TO_HA = { + "extraHigh": "extra_high", +} + +WASHER_SOIL_LEVEL_TO_HA = { + "none": "none", + "heavy": "heavy", + "normal": "normal", + "light": "light", + "extraLight": "extra_light", + "extraHeavy": "extra_heavy", + "up": "up", + "down": "down", +} + +WASHER_SPIN_LEVEL_TO_HA = { + "none": "none", + "rinseHold": "rinse_hold", + "noSpin": "no_spin", + "low": "low", + "extraLow": "extra_low", + "delicate": "delicate", + "medium": "medium", + "high": "high", + "extraHigh": "extra_high", + "200": "200", + "400": "400", + "600": "600", + "800": "800", + "1000": "1000", + "1200": "1200", + "1400": "1400", + "1600": "1600", +} + @dataclass(frozen=True, kw_only=True) class SmartThingsSelectDescription(SelectEntityDescription): @@ -26,7 +61,10 @@ class SmartThingsSelectDescription(SelectEntityDescription): options_attribute: Attribute status_attribute: Attribute command: Command + options_map: dict[str, str] | None = None default_options: list[str] | None = None + extra_components: list[str] | None = None + capability_ignore_list: list[Capability] | None = None CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { @@ -75,6 +113,35 @@ CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { command=Command.SET_AMOUNT, entity_category=EntityCategory.CONFIG, ), + Capability.SAMSUNG_CE_LAMP: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_LAMP, + translation_key="lamp", + options_attribute=Attribute.SUPPORTED_BRIGHTNESS_LEVEL, + status_attribute=Attribute.BRIGHTNESS_LEVEL, + command=Command.SET_BRIGHTNESS_LEVEL, + options_map=LAMP_TO_HA, + entity_category=EntityCategory.CONFIG, + extra_components=["hood"], + capability_ignore_list=[Capability.SAMSUNG_CE_CONNECTION_STATE], + ), + Capability.CUSTOM_WASHER_SPIN_LEVEL: SmartThingsSelectDescription( + key=Capability.CUSTOM_WASHER_SPIN_LEVEL, + translation_key="spin_level", + options_attribute=Attribute.SUPPORTED_WASHER_SPIN_LEVEL, + status_attribute=Attribute.WASHER_SPIN_LEVEL, + command=Command.SET_WASHER_SPIN_LEVEL, + options_map=WASHER_SPIN_LEVEL_TO_HA, + entity_category=EntityCategory.CONFIG, + ), + Capability.CUSTOM_WASHER_SOIL_LEVEL: SmartThingsSelectDescription( + key=Capability.CUSTOM_WASHER_SOIL_LEVEL, + translation_key="soil_level", + options_attribute=Attribute.SUPPORTED_WASHER_SOIL_LEVEL, + status_attribute=Attribute.WASHER_SOIL_LEVEL, + command=Command.SET_WASHER_SOIL_LEVEL, + options_map=WASHER_SOIL_LEVEL_TO_HA, + entity_category=EntityCategory.CONFIG, + ), } @@ -86,12 +153,25 @@ async def async_setup_entry( """Add select entities for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsSelectEntity( - entry_data.client, device, CAPABILITIES_TO_SELECT[capability] - ) + SmartThingsSelectEntity(entry_data.client, device, description, component) + for capability, description in CAPABILITIES_TO_SELECT.items() for device in entry_data.devices.values() - for capability in device.status[MAIN] - if capability in CAPABILITIES_TO_SELECT + for component in device.status + if capability in device.status[component] + and ( + component == MAIN + or ( + description.extra_components is not None + and component in description.extra_components + ) + ) + and ( + description.capability_ignore_list is None + or any( + capability not in device.status[component] + for capability in description.capability_ignore_list + ) + ) ) @@ -105,32 +185,42 @@ class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsSelectDescription, + component: str, ) -> None: """Initialize the instance.""" capabilities = {entity_description.key} if entity_description.requires_remote_control_status: capabilities.add(Capability.REMOTE_CONTROL_STATUS) - super().__init__(client, device, capabilities) + super().__init__(client, device, capabilities, component=component) self.entity_description = entity_description - self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{entity_description.key}_{entity_description.status_attribute}_{entity_description.status_attribute}" + self._attr_unique_id = f"{device.device.device_id}_{component}_{entity_description.key}_{entity_description.status_attribute}_{entity_description.status_attribute}" @property def options(self) -> list[str]: """Return the list of options.""" - return ( + options: list[str] = ( self.get_attribute_value( self.entity_description.key, self.entity_description.options_attribute ) or self.entity_description.default_options or [] ) + if self.entity_description.options_map: + options = [ + self.entity_description.options_map.get(option, option) + for option in options + ] + return options @property def current_option(self) -> str | None: """Return the current option.""" - return self.get_attribute_value( + option = self.get_attribute_value( self.entity_description.key, self.entity_description.status_attribute ) + if self.entity_description.options_map: + option = self.entity_description.options_map.get(option) + return option async def async_select_option(self, option: str) -> None: """Select an option.""" @@ -144,6 +234,15 @@ class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity): raise ServiceValidationError( "Can only be updated when remote control is enabled" ) + if self.entity_description.options_map: + option = next( + ( + key + for key, value in self.entity_description.options_map.items() + if value == option + ), + option, + ) await self.execute_device_command( self.entity_description.key, self.entity_description.command, diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 219e1dfe5c1..ef066c02130 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -26,6 +26,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfMass, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfVolume, ) @@ -45,6 +46,17 @@ THERMOSTAT_CAPABILITIES = { Capability.THERMOSTAT_MODE, } +COOKTOP_HEATING_MODES = { + "off": "off", + "manual": "manual", + "boost": "boost", + "keepWarm": "keep_warm", + "quickPreheat": "quick_preheat", + "defrost": "defrost", + "melt": "melt", + "simmer": "simmer", +} + JOB_STATE_MAP = { "airWash": "air_wash", "airwash": "air_wash", @@ -133,9 +145,13 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None + options_map: dict[str, str] | None = None + translation_placeholders_fn: Callable[[str], dict[str, str]] | None = None + component_fn: Callable[[str], bool] | None = None exists_fn: Callable[[Status], bool] | None = None use_temperature_unit: bool = False - deprecated: Callable[[ComponentStatus], str | None] | None = None + deprecated: Callable[[ComponentStatus], tuple[str, str] | None] | None = None + component_translation_key: dict[str, str] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -186,6 +202,15 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.ATMOSPHERIC_PRESSURE_MEASUREMENT: { + Attribute.ATMOSPHERIC_PRESSURE: [ + SmartThingsSensorEntityDescription( + key=Attribute.ATMOSPHERIC_PRESSURE, + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.AUDIO_VOLUME: { Attribute.VOLUME: [ SmartThingsSensorEntityDescription( @@ -193,7 +218,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="audio_volume", native_unit_of_measurement=PERCENTAGE, deprecated=( - lambda status: "media_player" + lambda status: ("2025.10.0", "media_player") if Capability.AUDIO_MUTE in status else None ), @@ -265,6 +290,41 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.SAMSUNG_CE_COOKTOP_HEATING_POWER: { + Attribute.MANUAL_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.MANUAL_LEVEL, + translation_key="manual_level", + translation_placeholders_fn=lambda component: { + "burner_id": component.split("-0")[-1] + }, + component_fn=lambda component: component.startswith("burner-0"), + ) + ], + Attribute.HEATING_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.HEATING_MODE, + translation_key="heating_mode", + options_attribute=Attribute.SUPPORTED_HEATING_MODES, + options_map=COOKTOP_HEATING_MODES, + device_class=SensorDeviceClass.ENUM, + translation_placeholders_fn=lambda component: { + "burner_id": component.split("-0")[-1] + }, + component_fn=lambda component: component.startswith("burner-0"), + ) + ], + }, + Capability.CUSTOM_COOKTOP_OPERATING_STATE: { + Attribute.COOKTOP_OPERATING_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.COOKTOP_OPERATING_STATE, + translation_key="cooktop_operating_state", + device_class=SensorDeviceClass.ENUM, + options_attribute=Attribute.SUPPORTED_COOKTOP_OPERATING_STATE, + ) + ] + }, Capability.DISHWASHER_OPERATING_STATE: { Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( @@ -374,6 +434,16 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, + Capability.SAMSUNG_CE_EHS_DIVERTER_VALVE: { + Attribute.POSITION: [ + SmartThingsSensorEntityDescription( + key=Attribute.POSITION, + translation_key="diverter_valve_position", + device_class=SensorDeviceClass.ENUM, + options=["room", "tank"], + ) + ] + }, Capability.ENERGY_METER: { Attribute.ENERGY: [ SmartThingsSensorEntityDescription( @@ -470,7 +540,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENUM, options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, value_fn=lambda value: value.lower() if value else None, - deprecated=lambda _: "media_player", + deprecated=lambda _: ("2025.10.0", "media_player"), ) ] }, @@ -479,7 +549,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_REPEAT_MODE, translation_key="media_playback_repeat", - deprecated=lambda _: "media_player", + deprecated=lambda _: ("2025.10.0", "media_player"), ) ] }, @@ -488,7 +558,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_SHUFFLE, translation_key="media_playback_shuffle", - deprecated=lambda _: "media_player", + deprecated=lambda _: ("2025.10.0", "media_player"), ) ] }, @@ -507,7 +577,7 @@ CAPABILITY_TO_SENSORS: dict[ ], device_class=SensorDeviceClass.ENUM, value_fn=lambda value: MEDIA_PLAYBACK_STATE_MAP.get(value, value), - deprecated=lambda _: "media_player", + deprecated=lambda _: ("2025.10.0", "media_player"), ) ] }, @@ -788,6 +858,16 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + deprecated=( + lambda status: ("2025.12.0", "dhw") + if Capability.CUSTOM_OUTING_MODE in status + else None + ), + component_fn=lambda component: component in {"freezer", "cooler"}, + component_translation_key={ + "freezer": "freezer_temperature", + "cooler": "cooler_temperature", + }, ) ] }, @@ -805,6 +885,11 @@ CAPABILITY_TO_SENSORS: dict[ }, THERMOSTAT_CAPABILITIES, ], + deprecated=( + lambda status: ("2025.12.0", "dhw") + if Capability.CUSTOM_OUTING_MODE in status + else None + ), ) ] }, @@ -1012,6 +1097,7 @@ UNITS = { "lux": LIGHT_LUX, "mG": None, "μg/m^3": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "kPa": UnitOfPressure.KPA, } @@ -1028,59 +1114,74 @@ async def async_setup_entry( for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks for capability, attributes in CAPABILITY_TO_SENSORS.items(): - if capability in device.status[MAIN]: - for attribute, descriptions in attributes.items(): - for description in descriptions: - if ( - not description.capability_ignore_list - or not any( - all( - capability in device.status[MAIN] - for capability in capability_list - ) - for capability_list in description.capability_ignore_list - ) - ) and ( - not description.exists_fn - or description.exists_fn( - device.status[MAIN][capability][attribute] - ) - ): + for component, capabilities in device.status.items(): + if capability in capabilities: + for attribute, descriptions in attributes.items(): + for description in descriptions: if ( - description.deprecated - and ( - reason := description.deprecated( - device.status[MAIN] + ( + not description.capability_ignore_list + or not any( + all( + capability in device.status[MAIN] + for capability in capability_list + ) + for capability_list in description.capability_ignore_list + ) + ) + and ( + not description.exists_fn + or description.exists_fn( + device.status[MAIN][capability][attribute] + ) + ) + and ( + component == MAIN + or ( + description.component_fn is not None + and description.component_fn(component) ) ) - is not None ): - if deprecate_entity( - hass, - entity_registry, - SENSOR_DOMAIN, - f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{description.key}", - f"deprecated_{reason}", - ): - entities.append( - SmartThingsSensor( - entry_data.client, - device, - description, - capability, - attribute, + if ( + description.deprecated + and ( + deprecation_info := description.deprecated( + device.status[MAIN] ) ) - continue - entities.append( - SmartThingsSensor( - entry_data.client, - device, - description, - capability, - attribute, + is not None + ): + version, reason = deprecation_info + if deprecate_entity( + hass, + entity_registry, + SENSOR_DOMAIN, + f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{description.key}", + f"deprecated_{reason}", + version, + ): + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + MAIN, + capability, + attribute, + ) + ) + continue + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + component, + capability, + attribute, + ) ) - ) async_add_entities(entities) @@ -1095,6 +1196,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsSensorEntityDescription, + component: str, capability: Capability, attribute: Attribute, ) -> None: @@ -1102,16 +1204,26 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): capabilities_to_subscribe = {capability} if entity_description.use_temperature_unit: capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) - super().__init__(client, device, capabilities_to_subscribe) - self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{entity_description.key}" + super().__init__(client, device, capabilities_to_subscribe, component=component) + self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{attribute}_{entity_description.key}" self._attribute = attribute self.capability = capability self.entity_description = entity_description + if self.entity_description.translation_placeholders_fn: + self._attr_translation_placeholders = ( + self.entity_description.translation_placeholders_fn(component) + ) + if self.entity_description.component_translation_key and component != MAIN: + self._attr_translation_key = ( + self.entity_description.component_translation_key[component] + ) @property def native_value(self) -> str | float | datetime | int | None: """Return the state of the sensor.""" res = self.get_attribute_value(self.capability, self._attribute) + if options_map := self.entity_description.options_map: + return options_map.get(res) return self.entity_description.value_fn(res) @property @@ -1148,5 +1260,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): ) ) is None: return [] + if options_map := self.entity_description.options_map: + return [options_map[option] for option in options] return [option.lower() for option in options] return super().options diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 81f4d34c8bb..8e972ac8aea 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -39,6 +39,9 @@ "dryer_wrinkle_prevent_active": { "name": "Wrinkle prevent active" }, + "keep_fresh_mode_active": { + "name": "Keep fresh mode active" + }, "filter_status": { "name": "Filter status" }, @@ -46,7 +49,7 @@ "name": "Freezer door" }, "cooler_door": { - "name": "Cooler door" + "name": "Fridge door" }, "cool_select_plus_door": { "name": "CoolSelect+ door" @@ -105,6 +108,18 @@ "washer_rinse_cycles": { "name": "Rinse cycles", "unit_of_measurement": "cycles" + }, + "hood_fan_speed": { + "name": "Fan speed" + }, + "freezer_temperature": { + "name": "Freezer temperature" + }, + "cooler_temperature": { + "name": "Fridge temperature" + }, + "cool_select_plus_temperature": { + "name": "CoolSelect+ temperature" } }, "select": { @@ -115,6 +130,17 @@ "stop": "[%key:common::state::stopped%]" } }, + "lamp": { + "name": "Lamp", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "low": "Low", + "mid": "Mid", + "high": "High", + "extra_high": "Extra high" + } + }, "detergent_amount": { "name": "Detergent dispense amount", "state": { @@ -134,6 +160,41 @@ "extra": "[%key:component::smartthings::entity::select::detergent_amount::state::extra%]", "custom": "[%key:component::smartthings::entity::select::detergent_amount::state::custom%]" } + }, + "spin_level": { + "name": "Spin level", + "state": { + "none": "None", + "rinse_hold": "Rinse hold", + "no_spin": "No spin", + "low": "[%key:common::state::low%]", + "extra_low": "Extra low", + "delicate": "Delicate", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "extra_high": "Extra high", + "200": "200", + "400": "400", + "600": "600", + "800": "800", + "1000": "1000", + "1200": "1200", + "1400": "1400", + "1600": "1600" + } + }, + "soil_level": { + "name": "Soil level", + "state": { + "none": "None", + "heavy": "Heavy", + "normal": "Normal", + "light": "Light", + "extra_light": "Extra light", + "extra_heavy": "Extra heavy", + "up": "Up", + "down": "Down" + } } }, "sensor": { @@ -172,6 +233,34 @@ "tested": "Tested" } }, + "cooktop_operating_state": { + "name": "Operating state", + "state": { + "ready": "[%key:component::smartthings::entity::sensor::oven_machine_state::state::ready%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "paused": "[%key:common::state::paused%]", + "finished": "[%key:component::smartthings::entity::sensor::oven_job_state::state::finished%]" + } + }, + "cooler_temperature": { + "name": "Fridge temperature" + }, + "manual_level": { + "name": "Burner {burner_id} level" + }, + "heating_mode": { + "name": "Burner {burner_id} heating mode", + "state": { + "off": "[%key:common::state::off%]", + "manual": "[%key:common::state::manual%]", + "boost": "Boost", + "keep_warm": "Keep warm", + "quick_preheat": "Quick preheat", + "defrost": "Defrost", + "melt": "Melt", + "simmer": "Simmer" + } + }, "dishwasher_machine_state": { "name": "Machine state", "state": { @@ -198,6 +287,13 @@ "completion_time": { "name": "Completion time" }, + "diverter_valve_position": { + "name": "Valve position", + "state": { + "room": "Room", + "tank": "Tank" + } + }, "dryer_mode": { "name": "Dryer mode" }, @@ -232,6 +328,9 @@ "equivalent_carbon_dioxide": { "name": "Equivalent carbon dioxide" }, + "freezer_temperature": { + "name": "Freezer temperature" + }, "formaldehyde": { "name": "Formaldehyde" }, @@ -354,7 +453,7 @@ } }, "oven_setpoint": { - "name": "Set point" + "name": "Setpoint" }, "energy_difference": { "name": "Energy difference" @@ -421,13 +520,13 @@ } }, "thermostat_cooling_setpoint": { - "name": "Cooling set point" + "name": "Cooling setpoint" }, "thermostat_fan_mode": { "name": "Fan mode" }, "thermostat_heating_setpoint": { - "name": "Heating set point" + "name": "Heating setpoint" }, "thermostat_mode": { "name": "Mode" @@ -504,6 +603,21 @@ }, "sabbath_mode": { "name": "Sabbath mode" + }, + "power_cool": { + "name": "Power cool" + }, + "power_freeze": { + "name": "Power freeze" + }, + "auto_cycle_link": { + "name": "Auto cycle link" + }, + "sanitize": { + "name": "Sanitize" + }, + "keep_fresh_mode": { + "name": "Keep fresh mode" } } }, @@ -530,23 +644,39 @@ }, "deprecated_switch_appliance_scripts": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the switch to fix this issue." }, "deprecated_switch_media_player": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nPlease update your dashboards and templates accordingly and disable the switch to fix this issue." }, "deprecated_switch_media_player_scripts": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the switch to fix this issue." + }, + "deprecated_switch_dhw": { + "title": "Heat pump switch deprecated", + "description": "The switch `{entity_id}` is deprecated and a water heater entity has been added to replace it.\n\nPlease update your dashboards and templates accordingly and disable the switch to fix this issue." + }, + "deprecated_switch_dhw_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_dhw::title%]", + "description": "The switch `{entity_id}` is deprecated and a water heater entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new water heater entity in the above automations or scripts and disable the switch to fix this issue." }, "deprecated_media_player": { "title": "Media player sensors deprecated", - "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nPlease update your dashboards, templates to use the new media player entity and disable the entity to fix this issue." + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nPlease update your dashboards and templates to use the new media player entity and disable the sensor to fix this issue." }, "deprecated_media_player_scripts": { "title": "Deprecated sensor detected in some automations or scripts", - "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new media player entity and disable the entity to fix this issue." + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new media player entity and disable the sensor to fix this issue." + }, + "deprecated_dhw": { + "title": "Water heater sensors deprecated", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a water heater entity.\n\nPlease update your dashboards and templates to use the new water heater entity and disable the sensor to fix this issue." + }, + "deprecated_dhw_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_dhw::title%]", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a water heater entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new water heater entity and disable the sensor to fix this issue." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 56e67ad2a13..56096dc6ab5 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -48,6 +48,9 @@ class SmartThingsSwitchEntityDescription(SwitchEntityDescription): status_attribute: Attribute component_translation_key: dict[str, str] | None = None + on_key: str = "on" + on_command: Command = Command.ON + off_command: Command = Command.OFF @dataclass(frozen=True, kw_only=True) @@ -71,7 +74,14 @@ CAPABILITY_TO_COMMAND_SWITCHES: dict[ status_attribute=Attribute.DRYER_WRINKLE_PREVENT, command=Command.SET_DRYER_WRINKLE_PREVENT, entity_category=EntityCategory.CONFIG, - ) + ), + Capability.SAMSUNG_CE_STEAM_CLOSET_AUTO_CYCLE_LINK: SmartThingsCommandSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STEAM_CLOSET_AUTO_CYCLE_LINK, + translation_key="auto_cycle_link", + status_attribute=Attribute.STEAM_CLOSET_AUTO_CYCLE_LINK, + command=Command.SET_STEAM_CLOSET_AUTO_CYCLE_LINK, + entity_category=EntityCategory.CONFIG, + ), } CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = { Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK: SmartThingsSwitchEntityDescription( @@ -91,6 +101,37 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio key=Capability.SAMSUNG_CE_SABBATH_MODE, translation_key="sabbath_mode", status_attribute=Attribute.STATUS, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_POWER_COOL: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_POWER_COOL, + translation_key="power_cool", + status_attribute=Attribute.ACTIVATED, + on_key="True", + on_command=Command.ACTIVATE, + off_command=Command.DEACTIVATE, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_POWER_FREEZE: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_POWER_FREEZE, + translation_key="power_freeze", + status_attribute=Attribute.ACTIVATED, + on_key="True", + on_command=Command.ACTIVATE, + off_command=Command.DEACTIVATE, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_STEAM_CLOSET_SANITIZE_MODE: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STEAM_CLOSET_SANITIZE_MODE, + translation_key="sanitize", + status_attribute=Attribute.STATUS, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE, + translation_key="keep_fresh_mode", + status_attribute=Attribute.STATUS, + entity_category=EntityCategory.CONFIG, ), } @@ -152,14 +193,24 @@ async def async_setup_entry( device.device.components[MAIN].manufacturer_category in INVALID_SWITCH_CATEGORIES ) - if media_player or appliance: - issue = "media_player" if media_player else "appliance" + dhw = Capability.SAMSUNG_CE_EHS_FSV_SETTINGS in device.status[MAIN] + if media_player or appliance or dhw: + if appliance: + issue = "appliance" + version = "2025.10.0" + elif media_player: + issue = "media_player" + version = "2025.10.0" + else: + issue = "dhw" + version = "2025.12.0" if deprecate_entity( hass, entity_registry, SWITCH_DOMAIN, f"{device.device.device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", f"deprecated_switch_{issue}", + version, ): entities.append( SmartThingsSwitch( @@ -210,14 +261,14 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Turn the switch off.""" await self.execute_device_command( self.switch_capability, - Command.OFF, + self.entity_description.off_command, ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.execute_device_command( self.switch_capability, - Command.ON, + self.entity_description.on_command, ) @property @@ -227,7 +278,7 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): self.get_attribute_value( self.switch_capability, self.entity_description.status_attribute ) - == "on" + == self.entity_description.on_key ) diff --git a/homeassistant/components/smartthings/util.py b/homeassistant/components/smartthings/util.py index b21652ca629..7d74e22477f 100644 --- a/homeassistant/components/smartthings/util.py +++ b/homeassistant/components/smartthings/util.py @@ -19,6 +19,7 @@ def deprecate_entity( platform_domain: str, entity_unique_id: str, issue_string: str, + version: str = "2025.10.0", ) -> bool: """Create an issue for deprecated entities.""" if entity_id := entity_registry.async_get_entity_id( @@ -51,7 +52,7 @@ def deprecate_entity( hass, DOMAIN, f"{issue_string}_{entity_id}", - breaks_in_ha_version="2025.10.0", + breaks_in_ha_version=version, is_fixable=False, severity=IssueSeverity.WARNING, translation_key=translation_key, diff --git a/homeassistant/components/smartthings/water_heater.py b/homeassistant/components/smartthings/water_heater.py new file mode 100644 index 00000000000..4b1aaaa5549 --- /dev/null +++ b/homeassistant/components/smartthings/water_heater.py @@ -0,0 +1,233 @@ +"""Support for water heaters through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.water_heater import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + STATE_ECO, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.unit_conversion import TemperatureConverter + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN, UNIT_MAP +from .entity import SmartThingsEntity + +OPERATION_MAP_TO_HA: dict[str, str] = { + "eco": STATE_ECO, + "std": STATE_HEAT_PUMP, + "force": STATE_HIGH_DEMAND, + "power": STATE_PERFORMANCE, +} + +HA_TO_OPERATION_MAP = {v: k for k, v in OPERATION_MAP_TO_HA.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add water heaters for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsWaterHeater(entry_data.client, device) + for device in entry_data.devices.values() + if all( + capability in device.status[MAIN] + for capability in ( + Capability.SWITCH, + Capability.AIR_CONDITIONER_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.SAMSUNG_CE_EHS_THERMOSTAT, + Capability.CUSTOM_OUTING_MODE, + ) + ) + and device.status[MAIN][Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].value + is not None + ) + + +class SmartThingsWaterHeater(SmartThingsEntity, WaterHeaterEntity): + """Define a SmartThings Water Heater.""" + + _attr_name = None + _attr_translation_key = "water_heater" + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Init the class.""" + super().__init__( + client, + device, + { + Capability.SWITCH, + Capability.AIR_CONDITIONER_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.CUSTOM_OUTING_MODE, + }, + ) + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit is not None + self._attr_temperature_unit = UNIT_MAP[unit] + + @property + def supported_features(self) -> WaterHeaterEntityFeature: + """Return the supported features.""" + features = ( + WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + | WaterHeaterEntityFeature.ON_OFF + ) + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on": + features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE + return features + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + min_temperature = TemperatureConverter.convert( + DEFAULT_MIN_TEMP, UnitOfTemperature.FAHRENHEIT, self._attr_temperature_unit + ) + return min(min_temperature, self.target_temperature_low) + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + max_temperature = TemperatureConverter.convert( + DEFAULT_MAX_TEMP, UnitOfTemperature.FAHRENHEIT, self._attr_temperature_unit + ) + return max(max_temperature, self.target_temperature_high) + + @property + def operation_list(self) -> list[str]: + """Return the list of available operation modes.""" + return [ + STATE_OFF, + *( + OPERATION_MAP_TO_HA[mode] + for mode in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ) + if mode in OPERATION_MAP_TO_HA + ), + ] + + @property + def current_operation(self) -> str | None: + """Return the current operation mode.""" + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": + return STATE_OFF + return OPERATION_MAP_TO_HA.get( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE + ) + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) + + @property + def target_temperature_low(self) -> float: + """Return the minimum temperature.""" + return self.get_attribute_value( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, Attribute.MINIMUM_SETPOINT + ) + + @property + def target_temperature_high(self) -> float: + """Return the maximum temperature.""" + return self.get_attribute_value( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, Attribute.MAXIMUM_SETPOINT + ) + + @property + def is_away_mode_on(self) -> bool: + """Return if away mode is on.""" + return ( + self.get_attribute_value( + Capability.CUSTOM_OUTING_MODE, Attribute.OUTING_MODE + ) + == "on" + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + if operation_mode == STATE_OFF: + await self.async_turn_off() + return + if self.current_operation == STATE_OFF: + await self.async_turn_on() + await self.execute_device_command( + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + argument=HA_TO_OPERATION_MAP[operation_mode], + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + await self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=kwargs[ATTR_TEMPERATURE], + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the water heater on.""" + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) + + async def async_turn_away_mode_on(self) -> None: + """Turn away mode on.""" + await self.execute_device_command( + Capability.CUSTOM_OUTING_MODE, + Command.SET_OUTING_MODE, + argument="on", + ) + + async def async_turn_away_mode_off(self) -> None: + """Turn away mode off.""" + await self.execute_device_command( + Capability.CUSTOM_OUTING_MODE, + Command.SET_OUTING_MODE, + argument="off", + ) diff --git a/homeassistant/components/smarttub/__init__.py b/homeassistant/components/smarttub/__init__.py index 8406fdc4c2f..178fd9a70e2 100644 --- a/homeassistant/components/smarttub/__init__.py +++ b/homeassistant/components/smarttub/__init__.py @@ -1,11 +1,9 @@ """SmartTub integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN, SMARTTUB_CONTROLLER -from .controller import SmartTubController +from .controller import SmartTubConfigEntry, SmartTubController PLATFORMS = [ Platform.BINARY_SENSOR, @@ -16,26 +14,21 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SmartTubConfigEntry) -> bool: """Set up a smarttub config entry.""" controller = SmartTubController(hass) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - SMARTTUB_CONTROLLER: controller, - } if not await controller.async_setup_entry(entry): return False + entry.runtime_data = controller + 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: SmartTubConfigEntry) -> bool: """Remove a smarttub 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/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index 2e8792140b0..a120650e84b 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -2,20 +2,23 @@ from __future__ import annotations -from smarttub import SpaError, SpaReminder +from typing import Any + +from smarttub import Spa, SpaError, SpaReminder import voluptuous as vol from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ATTR_ERRORS, ATTR_REMINDERS, DOMAIN, SMARTTUB_CONTROLLER +from .const import ATTR_ERRORS, ATTR_REMINDERS +from .controller import SmartTubConfigEntry from .entity import SmartTubEntity, SmartTubSensorBase # whether the reminder has been snoozed (bool) @@ -44,12 +47,12 @@ SNOOZE_REMINDER_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SmartTubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities for the binary sensors in the tub.""" - controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + controller = entry.runtime_data entities: list[BinarySensorEntity] = [] for spa in controller.spas: @@ -83,7 +86,9 @@ class SmartTubOnline(SmartTubSensorBase, BinarySensorEntity): # This seems to be very noisy and not generally useful, so disable by default. _attr_entity_registry_enabled_default = False - def __init__(self, coordinator, spa): + def __init__( + self, coordinator: DataUpdateCoordinator[dict[str, Any]], spa: Spa + ) -> None: """Initialize the entity.""" super().__init__(coordinator, spa, "Online", "online") @@ -98,7 +103,12 @@ class SmartTubReminder(SmartTubEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.PROBLEM - def __init__(self, coordinator, spa, reminder): + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + spa: Spa, + reminder: SpaReminder, + ) -> None: """Initialize the entity.""" super().__init__( coordinator, @@ -119,7 +129,7 @@ class SmartTubReminder(SmartTubEntity, BinarySensorEntity): return self.reminder.remaining_days == 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_REMINDER_SNOOZED: self.reminder.snoozed, @@ -145,7 +155,9 @@ class SmartTubError(SmartTubEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.PROBLEM - def __init__(self, coordinator, spa): + def __init__( + self, coordinator: DataUpdateCoordinator[dict[str, Any]], spa: Spa + ) -> None: """Initialize the entity.""" super().__init__( coordinator, @@ -167,7 +179,7 @@ class SmartTubError(SmartTubEntity, BinarySensorEntity): return self.error is not None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if (error := self.error) is None: return {} diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index f5759f32fa3..62a81857764 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -14,13 +14,13 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.unit_conversion import TemperatureConverter +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SMARTTUB_CONTROLLER +from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP +from .controller import SmartTubConfigEntry from .entity import SmartTubEntity PRESET_DAY = "day" @@ -43,12 +43,12 @@ HVAC_ACTIONS = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SmartTubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate entity for the thermostat in the tub.""" - controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + controller = entry.runtime_data entities = [ SmartTubThermostat(controller.coordinator, spa) for spa in controller.spas @@ -69,9 +69,13 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = DEFAULT_MIN_TEMP + _attr_max_temp = DEFAULT_MAX_TEMP _attr_preset_modes = list(PRESET_MODES.values()) - def __init__(self, coordinator, spa): + def __init__( + self, coordinator: DataUpdateCoordinator[dict[str, Any]], spa: Spa + ) -> None: """Initialize the entity.""" super().__init__(coordinator, spa, "Thermostat") @@ -90,23 +94,7 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): raise NotImplementedError(hvac_mode) @property - def min_temp(self): - """Return the minimum temperature.""" - min_temp = DEFAULT_MIN_TEMP - return TemperatureConverter.convert( - min_temp, UnitOfTemperature.CELSIUS, self.temperature_unit - ) - - @property - def max_temp(self): - """Return the maximum temperature.""" - max_temp = DEFAULT_MAX_TEMP - return TemperatureConverter.convert( - max_temp, UnitOfTemperature.CELSIUS, self.temperature_unit - ) - - @property - def preset_mode(self): + def preset_mode(self) -> str: """Return the current preset mode.""" return PRESET_MODES[self.spa_status.heat_mode] diff --git a/homeassistant/components/smarttub/const.py b/homeassistant/components/smarttub/const.py index f97ef65a54c..dadc66da942 100644 --- a/homeassistant/components/smarttub/const.py +++ b/homeassistant/components/smarttub/const.py @@ -4,8 +4,6 @@ DOMAIN = "smarttub" EVENT_SMARTTUB = "smarttub" -SMARTTUB_CONTROLLER = "smarttub_controller" - SCAN_INTERVAL = 60 POLLING_TIMEOUT = 10 diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index 353e2093997..d8299bbd786 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -3,13 +3,15 @@ import asyncio from datetime import timedelta import logging +from typing import Any from aiohttp import client_exceptions -from smarttub import APIError, LoginFailed, SmartTub +from smarttub import APIError, LoginFailed, SmartTub, Spa from smarttub.api import Account +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -29,19 +31,21 @@ from .helpers import get_spa_name _LOGGER = logging.getLogger(__name__) +type SmartTubConfigEntry = ConfigEntry[SmartTubController] + class SmartTubController: """Interface between Home Assistant and the SmartTub API.""" - def __init__(self, hass): + coordinator: DataUpdateCoordinator[dict[str, Any]] + spas: list[Spa] + _account: Account + + def __init__(self, hass: HomeAssistant) -> None: """Initialize an interface to SmartTub.""" self._hass = hass - self._account = None - self.spas = set() - self.coordinator = None - - async def async_setup_entry(self, entry): + async def async_setup_entry(self, entry: SmartTubConfigEntry) -> bool: """Perform initial setup. Authenticate, query static state, set up polling, and otherwise make @@ -79,7 +83,7 @@ class SmartTubController: return True - async def async_update_data(self): + async def async_update_data(self) -> dict[str, Any]: """Query the API and return the new state.""" data = {} @@ -92,7 +96,7 @@ class SmartTubController: return data - async def _get_spa_data(self, spa): + async def _get_spa_data(self, spa: Spa) -> dict[str, Any]: full_status, reminders, errors = await asyncio.gather( spa.get_status_full(), spa.get_reminders(), @@ -107,7 +111,7 @@ class SmartTubController: } @callback - def async_register_devices(self, entry): + def async_register_devices(self, entry: SmartTubConfigEntry) -> None: """Register devices with the device registry for all spas.""" device_registry = dr.async_get(self._hass) for spa in self.spas: @@ -119,11 +123,8 @@ class SmartTubController: model=spa.model, ) - async def login(self, email, password) -> Account: - """Retrieve the account corresponding to the specified email and password. - - Returns None if the credentials are invalid. - """ + async def login(self, email: str, password: str) -> Account: + """Retrieve the account corresponding to the specified email and password.""" api = SmartTub(async_get_clientsession(self._hass)) diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index f9ab1d10bfe..069fd50c5f2 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -1,6 +1,8 @@ """Base classes for SmartTub entities.""" -import smarttub +from typing import Any + +from smarttub import Spa, SpaState from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -16,7 +18,10 @@ class SmartTubEntity(CoordinatorEntity): """Base class for SmartTub entities.""" def __init__( - self, coordinator: DataUpdateCoordinator, spa: smarttub.Spa, entity_name + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + spa: Spa, + entity_name: str, ) -> None: """Initialize the entity. @@ -36,7 +41,7 @@ class SmartTubEntity(CoordinatorEntity): self._attr_name = f"{spa_name} {entity_name}" @property - def spa_status(self) -> smarttub.SpaState: + def spa_status(self) -> SpaState: """Retrieve the result of Spa.get_status().""" return self.coordinator.data[self.spa.id].get("status") @@ -45,7 +50,13 @@ class SmartTubEntity(CoordinatorEntity): class SmartTubSensorBase(SmartTubEntity): """Base class for SmartTub sensors.""" - def __init__(self, coordinator, spa, sensor_name, state_key): + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + spa: Spa, + sensor_name: str, + state_key: str, + ) -> None: """Initialize the entity.""" super().__init__(coordinator, spa, sensor_name) self._state_key = state_key diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py index dda936aa56a..b6e056d37e0 100644 --- a/homeassistant/components/smarttub/light.py +++ b/homeassistant/components/smarttub/light.py @@ -12,29 +12,24 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ( - ATTR_LIGHTS, - DEFAULT_LIGHT_BRIGHTNESS, - DEFAULT_LIGHT_EFFECT, - DOMAIN, - SMARTTUB_CONTROLLER, -) +from .const import ATTR_LIGHTS, DEFAULT_LIGHT_BRIGHTNESS, DEFAULT_LIGHT_EFFECT +from .controller import SmartTubConfigEntry from .entity import SmartTubEntity from .helpers import get_spa_name async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SmartTubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for any lights in the tub.""" - controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + controller = entry.runtime_data entities = [ SmartTubLight(controller.coordinator, light) @@ -52,7 +47,9 @@ class SmartTubLight(SmartTubEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_supported_features = LightEntityFeature.EFFECT - def __init__(self, coordinator, light): + def __init__( + self, coordinator: DataUpdateCoordinator[dict[str, Any]], light: SpaLight + ) -> None: """Initialize the entity.""" super().__init__(coordinator, light.spa, "light") self.light_zone = light.zone diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index b2bb1170d09..5116bfb3aee 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -1,18 +1,19 @@ """Platform for sensor integration.""" from enum import Enum +from typing import Any import smarttub import voluptuous as vol from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, SMARTTUB_CONTROLLER +from .controller import SmartTubConfigEntry from .entity import SmartTubSensorBase # the desired duration, in hours, of the cycle @@ -44,12 +45,12 @@ SET_SECONDARY_FILTRATION_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SmartTubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities for the sensors in the tub.""" - controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + controller = entry.runtime_data entities = [] for spa in controller.spas: @@ -107,7 +108,9 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity): class SmartTubPrimaryFiltrationCycle(SmartTubSensor): """The primary filtration cycle.""" - def __init__(self, coordinator, spa): + def __init__( + self, coordinator: DataUpdateCoordinator[dict[str, Any]], spa: smarttub.Spa + ) -> None: """Initialize the entity.""" super().__init__( coordinator, spa, "Primary Filtration Cycle", "primary_filtration" @@ -124,7 +127,7 @@ class SmartTubPrimaryFiltrationCycle(SmartTubSensor): return self.cycle.status.name.lower() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_DURATION: self.cycle.duration, @@ -145,7 +148,9 @@ class SmartTubPrimaryFiltrationCycle(SmartTubSensor): class SmartTubSecondaryFiltrationCycle(SmartTubSensor): """The secondary filtration cycle.""" - def __init__(self, coordinator, spa): + def __init__( + self, coordinator: DataUpdateCoordinator[dict[str, Any]], spa: smarttub.Spa + ) -> None: """Initialize the entity.""" super().__init__( coordinator, spa, "Secondary Filtration Cycle", "secondary_filtration" @@ -162,7 +167,7 @@ class SmartTubSecondaryFiltrationCycle(SmartTubSensor): return self.cycle.status.name.lower() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_CYCLE_LAST_UPDATED: self.cycle.last_updated.isoformat(), diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py index 2dedad8e18a..12d15d63f9b 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -6,23 +6,24 @@ from typing import Any from smarttub import SpaPump from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import API_TIMEOUT, ATTR_PUMPS, DOMAIN, SMARTTUB_CONTROLLER +from .const import API_TIMEOUT, ATTR_PUMPS +from .controller import SmartTubConfigEntry from .entity import SmartTubEntity from .helpers import get_spa_name async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SmartTubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch entities for the pumps on the tub.""" - controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + controller = entry.runtime_data entities = [ SmartTubPump(controller.coordinator, pump) @@ -36,7 +37,9 @@ async def async_setup_entry( class SmartTubPump(SmartTubEntity, SwitchEntity): """A pump on a spa.""" - def __init__(self, coordinator, pump: SpaPump) -> None: + def __init__( + self, coordinator: DataUpdateCoordinator[dict[str, Any]], pump: SpaPump + ) -> None: """Initialize the entity.""" super().__init__(coordinator, pump.spa, "pump") self.pump_id = pump.id diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index f834392ea13..67d9997a105 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) class SmButtonDescription(ButtonEntityDescription): """Class to describe a Button entity.""" - press_fn: Callable[[CmdWrapper], Awaitable[None]] + press_fn: Callable[[CmdWrapper, int], Awaitable[None]] BUTTONS: list[SmButtonDescription] = [ @@ -40,19 +40,19 @@ BUTTONS: list[SmButtonDescription] = [ key="core_restart", translation_key="core_restart", device_class=ButtonDeviceClass.RESTART, - press_fn=lambda cmd: cmd.reboot(), + press_fn=lambda cmd, idx: cmd.reboot(), ), SmButtonDescription( key="zigbee_restart", translation_key="zigbee_restart", device_class=ButtonDeviceClass.RESTART, - press_fn=lambda cmd: cmd.zb_restart(), + press_fn=lambda cmd, idx: cmd.zb_restart(), ), SmButtonDescription( key="zigbee_flash_mode", translation_key="zigbee_flash_mode", entity_registry_enabled_default=False, - press_fn=lambda cmd: cmd.zb_bootloader(), + press_fn=lambda cmd, idx: cmd.zb_bootloader(), ), ] @@ -60,7 +60,7 @@ ROUTER = SmButtonDescription( key="reconnect_zigbee_router", translation_key="reconnect_zigbee_router", entity_registry_enabled_default=False, - press_fn=lambda cmd: cmd.zb_router(), + press_fn=lambda cmd, idx: cmd.zb_router(idx=idx), ) @@ -71,23 +71,32 @@ async def async_setup_entry( ) -> None: """Set up SMLIGHT buttons based on a config entry.""" coordinator = entry.runtime_data.data + radios = coordinator.data.info.radios async_add_entities(SmButton(coordinator, button) for button in BUTTONS) - entity_created = False + entity_created = [False, False] @callback def _check_router(startup: bool = False) -> None: - nonlocal entity_created + def router_entity(router: SmButtonDescription, idx: int) -> None: + nonlocal entity_created + zb_type = coordinator.data.info.radios[idx].zb_type - if coordinator.data.info.zb_type == 1 and not entity_created: - async_add_entities([SmButton(coordinator, ROUTER)]) - entity_created = True - elif coordinator.data.info.zb_type != 1 and (startup or entity_created): - entity_registry = er.async_get(hass) - if entity_id := entity_registry.async_get_entity_id( - BUTTON_DOMAIN, DOMAIN, f"{coordinator.unique_id}-{ROUTER.key}" - ): - entity_registry.async_remove(entity_id) + if zb_type == 1 and not entity_created[idx]: + async_add_entities([SmButton(coordinator, router, idx)]) + entity_created[idx] = True + elif zb_type != 1 and (startup or entity_created[idx]): + entity_registry = er.async_get(hass) + button = f"_{idx}" if idx else "" + if entity_id := entity_registry.async_get_entity_id( + BUTTON_DOMAIN, + DOMAIN, + f"{coordinator.unique_id}-{router.key}{button}", + ): + entity_registry.async_remove(entity_id) + + for idx, _ in enumerate(radios): + router_entity(ROUTER, idx) coordinator.async_add_listener(_check_router) _check_router(startup=True) @@ -104,13 +113,16 @@ class SmButton(SmEntity, ButtonEntity): self, coordinator: SmDataUpdateCoordinator, description: SmButtonDescription, + idx: int = 0, ) -> None: """Initialize SLZB-06 button entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self.idx = idx + button = f"_{idx}" if idx else "" + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}{button}" async def async_press(self) -> None: """Trigger button press.""" - await self.entity_description.press_fn(self.coordinator.client.cmds) + await self.entity_description.press_fn(self.coordinator.client.cmds, self.idx) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index b2a03a737fc..f47960a65bd 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["pysmlight==0.2.4"], + "requirements": ["pysmlight==0.2.5"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 2d18d44de3a..6c7c5374f7d 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -6,9 +6,14 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_NAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -41,6 +46,7 @@ CONFIG_SCHEMA = vol.Schema( }, extra=vol.ALLOW_EXTRA, ) +DEPRECATED_ISSUE_ID = f"deprecated_system_packages_config_flow_integration_{DOMAIN}" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -52,6 +58,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Configure Gammu state machine.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + DEPRECATED_ISSUE_ID, + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_config_flow_integration", + translation_placeholders={ + "integration_title": "SMS notifications via GSM-modem", + }, + ) device = entry.data[CONF_DEVICE] connection_mode = "at" @@ -101,4 +120,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway = hass.data[DOMAIN].pop(SMS_GATEWAY)[GATEWAY] await gateway.terminate_async() + if not hass.config_entries.async_loaded_entries(DOMAIN): + async_delete_issue(hass, HOMEASSISTANT_DOMAIN, DEPRECATED_ISSUE_ID) + return unload_ok diff --git a/homeassistant/components/snips/__init__.py b/homeassistant/components/snips/__init__.py index 70837b95ec5..293caeaedac 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -7,8 +7,13 @@ import logging import voluptuous as vol from homeassistant.components import mqtt -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, +) from homeassistant.helpers import config_validation as cv, intent +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType DOMAIN = "snips" @@ -91,6 +96,20 @@ SERVICE_SCHEMA_FEEDBACK = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Snips component.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Snips", + }, + ) # Make sure MQTT integration is enabled and the client is available if not await mqtt.async_wait_for_mqtt_client(hass): diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index 1cdec0389fe..61420c152a5 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -42,7 +42,7 @@ SENSOR_DESCRIPTIONS: dict[tuple[Units, bool], SensorEntityDescription] = { key=f"{Units.KWH}_{False}", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), (Units.KWH, True): SensorEntityDescription( key=f"{Units.KWH}_{True}", diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 322beaed092..e2e981b293c 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -86,7 +86,7 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity): @property def available(self) -> bool: """Return whether this device is available.""" - return self.speaker.available and (self.speaker.charging is not None) + return self.speaker.available and self.speaker.charging is not None class SonosMicrophoneSensorEntity(SonosEntity, BinarySensorEntity): diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index cda40729dbc..614be2b1817 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -31,9 +31,12 @@ SONOS_ALBUM_ARTIST = "album_artists" SONOS_TRACKS = "tracks" SONOS_COMPOSER = "composers" SONOS_RADIO = "radio" +SONOS_SHARE = "share" SONOS_OTHER_ITEM = "other items" SONOS_AUDIO_BOOK = "audio book" +MEDIA_TYPE_DIRECTORY = MediaClass.DIRECTORY + SONOS_STATE_PLAYING = "PLAYING" SONOS_STATE_TRANSITIONING = "TRANSITIONING" @@ -43,12 +46,14 @@ EXPANDABLE_MEDIA_TYPES = [ MediaType.COMPOSER, MediaType.GENRE, MediaType.PLAYLIST, + MEDIA_TYPE_DIRECTORY, SONOS_ALBUM, SONOS_ALBUM_ARTIST, SONOS_ARTIST, SONOS_GENRE, SONOS_COMPOSER, SONOS_PLAYLISTS, + SONOS_SHARE, ] SONOS_TO_MEDIA_CLASSES = { @@ -59,6 +64,8 @@ SONOS_TO_MEDIA_CLASSES = { SONOS_GENRE: MediaClass.GENRE, SONOS_PLAYLISTS: MediaClass.PLAYLIST, SONOS_TRACKS: MediaClass.TRACK, + SONOS_SHARE: MediaClass.DIRECTORY, + "object.container": MediaClass.DIRECTORY, "object.container.album.musicAlbum": MediaClass.ALBUM, "object.container.genre.musicGenre": MediaClass.PLAYLIST, "object.container.person.composer": MediaClass.PLAYLIST, @@ -79,6 +86,7 @@ SONOS_TO_MEDIA_TYPES = { SONOS_GENRE: MediaType.GENRE, SONOS_PLAYLISTS: MediaType.PLAYLIST, SONOS_TRACKS: MediaType.TRACK, + "object.container": MEDIA_TYPE_DIRECTORY, "object.container.album.musicAlbum": MediaType.ALBUM, "object.container.genre.musicGenre": MediaType.PLAYLIST, "object.container.person.composer": MediaType.PLAYLIST, @@ -97,6 +105,7 @@ MEDIA_TYPES_TO_SONOS: dict[MediaType | str, str] = { MediaType.GENRE: SONOS_GENRE, MediaType.PLAYLIST: SONOS_PLAYLISTS, MediaType.TRACK: SONOS_TRACKS, + MEDIA_TYPE_DIRECTORY: SONOS_SHARE, } SONOS_TYPES_MAPPING = { @@ -127,6 +136,7 @@ LIBRARY_TITLES_MAPPING = { "A:GENRE": "Genres", "A:PLAYLISTS": "Playlists", "A:TRACKS": "Tracks", + "S:": "Folders", } PLAYABLE_MEDIA_TYPES = [ diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 333c4809e62..f8b3dbbe492 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -106,6 +106,9 @@ class SonosFavorites(SonosHouseholdCoordinator): def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: """Update cache of known favorites and return if cache has changed.""" new_favorites = soco.music_library.get_sonos_favorites(full_album_art_uri=True) + new_playlists = soco.music_library.get_music_library_information( + "sonos_playlists", full_album_art_uri=True + ) # Polled update_id values do not match event_id values # Each speaker can return a different polled update_id @@ -131,6 +134,16 @@ class SonosFavorites(SonosHouseholdCoordinator): except SoCoException as ex: # Skip unknown types _LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex) + for playlist in new_playlists: + playlist_reference = DidlFavorite( + title=playlist.title, + parent_id=playlist.parent_id, + item_id=playlist.item_id, + resources=playlist.resources, + desc=playlist.desc, + ) + playlist_reference.reference = playlist + self._favorites.append(playlist_reference) _LOGGER.debug( "Cached %s favorites for household %s using %s", diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 16b425dae50..255daf22829 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -9,7 +9,7 @@ import logging from typing import cast import urllib.parse -from soco.data_structures import DidlObject +from soco.data_structures import DidlContainer, DidlObject from soco.ms_data_structures import MusicServiceItem from soco.music_library import MusicLibrary @@ -32,6 +32,7 @@ from .const import ( SONOS_ALBUM, SONOS_ALBUM_ARTIST, SONOS_GENRE, + SONOS_SHARE, SONOS_TO_MEDIA_CLASSES, SONOS_TO_MEDIA_TYPES, SONOS_TRACKS, @@ -105,6 +106,24 @@ def media_source_filter(item: BrowseMedia) -> bool: return item.media_content_type.startswith("audio/") +def _get_title(id_string: str) -> str: + """Extract a suitable title from the content id string.""" + if id_string.startswith("S:"): + # Format is S://server/share/folder + # If just S: this will be in the mappings; otherwise use the last folder in path. + title = LIBRARY_TITLES_MAPPING.get( + id_string, urllib.parse.unquote(id_string.split("/")[-1]) + ) + else: + parts = id_string.split("/") + title = ( + urllib.parse.unquote(parts[1]) + if len(parts) > 1 + else LIBRARY_TITLES_MAPPING.get(id_string, id_string) + ) + return title + + async def async_browse_media( hass: HomeAssistant, speaker: SonosSpeaker, @@ -240,10 +259,7 @@ def build_item_response( thumbnail = get_thumbnail_url(search_type, payload["idstring"]) if not title: - try: - title = urllib.parse.unquote(payload["idstring"].split("/")[1]) - except IndexError: - title = LIBRARY_TITLES_MAPPING[payload["idstring"]] + title = _get_title(id_string=payload["idstring"]) try: media_class = SONOS_TO_MEDIA_CLASSES[ @@ -288,12 +304,12 @@ def item_payload(item: DidlObject, get_thumbnail_url=None) -> BrowseMedia: thumbnail = get_thumbnail_url(media_class, content_id, item=item) return BrowseMedia( - title=item.title, + title=_get_title(item.item_id) if item.title is None else item.title, thumbnail=thumbnail, media_class=media_class, media_content_id=content_id, media_content_type=SONOS_TO_MEDIA_TYPES[media_type], - can_play=can_play(item.item_class), + can_play=can_play(item.item_class, item_id=content_id), can_expand=can_expand(item), ) @@ -396,6 +412,10 @@ def library_payload(media_library: MusicLibrary, get_thumbnail_url=None) -> Brow with suppress(UnknownMediaType): children.append(item_payload(item, get_thumbnail_url)) + # Add entry for Folders at the top level of the music library. + didl_item = DidlContainer(title="Folders", parent_id="", item_id="S:") + children.append(item_payload(didl_item, get_thumbnail_url)) + return BrowseMedia( title="Music Library", media_class=MediaClass.DIRECTORY, @@ -508,12 +528,16 @@ def get_media_type(item: DidlObject) -> str: return SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0], item.item_class) -def can_play(item: DidlObject) -> bool: +def can_play(item_class: str, item_id: str | None = None) -> bool: """Test if playable. Used by async_browse_media. """ - return SONOS_TO_MEDIA_TYPES.get(item) in PLAYABLE_MEDIA_TYPES + # Folders are playable once we reach the folder level. + # Format is S://server_address/share/folder + if item_id and item_id.startswith("S:") and item_class == "object.container": + return item_id.count("/") >= 4 + return SONOS_TO_MEDIA_TYPES.get(item_class) in PLAYABLE_MEDIA_TYPES def can_expand(item: DidlObject) -> bool: @@ -565,6 +589,19 @@ def get_media( matches = media_library.get_music_library_information( search_type, search_term=search_term, full_album_art_uri=True ) + elif search_type == SONOS_SHARE: + # In order to get the MusicServiceItem, we browse the parent folder + # and find one that matches on item_id. + parts = item_id.rstrip("/").split("/") + parent_folder = "/".join(parts[:-1]) + matches = media_library.browse_by_idstring( + search_type, parent_folder, full_album_art_uri=True + ) + result = next( + (item for item in matches if (item_id == item.item_id)), + None, + ) + matches = [result] else: # When requesting media by album_artist, composer, genre use the browse interface # to navigate the hierarchy. This occurs when invoked from media browser or service diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index a774de0ae5b..f1f95659469 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -52,7 +52,8 @@ from homeassistant.helpers.event import async_call_later from . import UnjoinData, media_browser from .const import ( DATA_SONOS, - DOMAIN as SONOS_DOMAIN, + DOMAIN, + MEDIA_TYPE_DIRECTORY, MEDIA_TYPES_TO_SONOS, MODELS_LINEIN_AND_TV, MODELS_LINEIN_ONLY, @@ -119,7 +120,7 @@ async def async_setup_entry( _LOGGER.debug("Creating media_player on %s", speaker.zone_name) async_add_entities([SonosMediaPlayerEntity(speaker)]) - @service.verify_domain_control(hass, SONOS_DOMAIN) + @service.verify_domain_control(hass, DOMAIN) async def async_service_handle(service_call: ServiceCall) -> None: """Handle dispatched services.""" assert platform is not None @@ -151,11 +152,11 @@ async def async_setup_entry( ) hass.services.async_register( - SONOS_DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema + DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema ) hass.services.async_register( - SONOS_DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema + DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema ) platform.async_register_entity_service( @@ -448,7 +449,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if len(fav) != 1: raise ServiceValidationError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_favorite", translation_placeholders={ "name": name, @@ -577,7 +578,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) else: raise HomeAssistantError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="announce_media_error", translation_placeholders={ "media_id": media_id, @@ -656,6 +657,10 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_id, timeout=LONG_SERVICE_TIMEOUT ) soco.play_from_queue(0) + elif media_type == MEDIA_TYPE_DIRECTORY: + self._play_media_directory( + soco=soco, media_type=media_type, media_id=media_id, enqueue=enqueue + ) elif media_type in {MediaType.MUSIC, MediaType.TRACK}: # If media ID is a relative URL, we serve it from HA. media_id = async_process_play_media_url(self.hass, media_id) @@ -684,7 +689,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): playlist = next((p for p in playlists if p.title == media_id), None) if not playlist: raise ServiceValidationError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_sonos_playlist", translation_placeholders={ "name": media_id, @@ -697,7 +702,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): item = media_browser.get_media(self.media.library, media_id, media_type) if not item: raise ServiceValidationError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_media", translation_placeholders={ "media_id": media_id, @@ -706,7 +711,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self._play_media_queue(soco, item, enqueue) else: raise ServiceValidationError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_content_type", translation_placeholders={ "media_type": media_type, @@ -738,6 +743,25 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) + def _play_media_directory( + self, + soco: SoCo, + media_type: MediaType | str, + media_id: str, + enqueue: MediaPlayerEnqueue, + ): + """Play a directory from a music library share.""" + item = media_browser.get_media(self.media.library, media_id, media_type) + if not item: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media", + translation_placeholders={ + "media_id": media_id, + }, + ) + self._play_media_queue(soco, item, enqueue) + @soco_error() def set_sleep_timer(self, sleep_time: int) -> None: """Set the timer on the player.""" diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index ce4774a4138..052dbd990b2 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -20,7 +20,7 @@ from homeassistant.helpers.event import async_track_time_change from .const import ( DATA_SONOS, - DOMAIN as SONOS_DOMAIN, + DOMAIN, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM, SONOS_CREATE_SWITCHES, @@ -276,7 +276,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): new_device = device_registry.async_get_or_create( config_entry_id=cast(str, entity.config_entry_id), - identifiers={(SONOS_DOMAIN, self.soco.uid)}, + identifiers={(DOMAIN, self.soco.uid)}, connections={(dr.CONNECTION_NETWORK_MAC, self.speaker.mac_address)}, ) if ( diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index f024c4ef4f7..c4d993cc22a 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -64,7 +64,7 @@ class SonyProjector(SwitchEntity): self._attributes = {} @property - def available(self): + def available(self) -> bool: """Return if projector is available.""" return self._available diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 27b8da7cecf..80fcc777e73 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -8,6 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["spotifyaio"], - "requirements": ["spotifyaio==0.8.11"], - "zeroconf": ["_spotify-connect._tcp.local."] + "requirements": ["spotifyaio==0.8.11"] } diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index ac861e72b72..f9b8044e992 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -106,6 +106,7 @@ "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", @@ -127,6 +128,7 @@ "state_class": { "options": { "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]", "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%]" } diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 13c27f064f7..596a44c498c 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -3,6 +3,7 @@ from asyncio import timeout from dataclasses import dataclass from datetime import datetime +from http import HTTPStatus import logging from pysqueezebox import Player, Server @@ -16,7 +17,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import ( @@ -56,6 +61,8 @@ PLATFORMS = [ Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, ] @@ -92,15 +99,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - status = await lms.async_query( "serverstatus", "-", "-", "prefs:libraryname" ) - except Exception as err: + except TimeoutError as err: # Specifically catch timeout + _LOGGER.warning("Timeout connecting to LMS %s: %s", host, err) raise ConfigEntryNotReady( - f"Error communicating config not read for {host}" + translation_domain=DOMAIN, + translation_key="init_timeout", + translation_placeholders={ + "host": str(host), + }, ) from err if not status: - raise ConfigEntryNotReady(f"Error Config Not read for {host}") + # pysqueezebox's async_query returns None on various issues, + # including HTTP errors where it sets lms.http_status. + http_status = getattr(lms, "http_status", "N/A") + + if http_status == HTTPStatus.UNAUTHORIZED: + _LOGGER.warning("Authentication failed for Squeezebox server %s", host) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="init_auth_failed", + translation_placeholders={ + "host": str(host), + }, + ) + + # For other errors where status is None (e.g., server error, connection refused by server) + _LOGGER.warning( + "LMS %s returned no status or an error (HTTP status: %s). Retrying setup", + host, + http_status, + ) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="init_get_status_failed", + translation_placeholders={ + "host": str(host), + "http_status": str(http_status), + }, + ) + + # If we are here, status is a valid dictionary _LOGGER.debug("LMS Status for setup = %s", status) + # Check for essential keys in status before using them + if STATUS_QUERY_UUID not in status: + _LOGGER.error("LMS %s status response missing UUID", host) + # This is a non-recoverable error with the current server response + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="init_missing_uuid", + translation_placeholders={ + "host": str(host), + }, + ) + lms.uuid = status[STATUS_QUERY_UUID] _LOGGER.debug("LMS %s = '%s' with uuid = %s ", lms.name, host, lms.uuid) lms.name = ( diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index daae8703597..1045e526ee3 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -17,6 +17,9 @@ from . import SqueezeboxConfigEntry from .const import STATUS_SENSOR_NEEDSRESTART, STATUS_SENSOR_RESCAN from .entity import LMSStatusEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key=STATUS_SENSOR_RESCAN, diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 3f4af99fffd..03df289a2fd 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_internal_request -from .const import UNPLAYABLE_TYPES +from .const import DOMAIN, UNPLAYABLE_TYPES LIBRARY = [ "favorites", @@ -50,21 +50,33 @@ MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { MediaType.GENRE: "genre", MediaType.APPS: "apps", "radios": "radios", + "favorite": "favorite", } SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.ALBUM: "album_id", + "albums": "album_id", MediaType.ARTIST: "artist_id", + "artists": "artist_id", MediaType.TRACK: "track_id", + "tracks": "track_id", MediaType.PLAYLIST: "playlist_id", + "playlists": "playlist_id", MediaType.GENRE: "genre_id", + "genres": "genre_id", + "favorite": "item_id", "favorites": "item_id", MediaType.APPS: "item_id", + "app": "item_id", + "radios": "item_id", + "radio": "item_id", } CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = { "favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "favorite": {"item": "favorite", "children": ""}, "radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "radio": {"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}, @@ -100,6 +112,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[ "album artists": MediaType.ARTIST, MediaType.APPS: MediaType.APP, MediaType.APP: MediaType.TRACK, + "favorite": None, } @@ -191,7 +204,7 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: return BrowseMedia( media_content_id=item["id"], title=item["title"], - media_content_type="favorites", + media_content_type="favorite", media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"], can_expand=bool(item.get("hasitems")), can_play=bool(item["isaudio"] and item.get("url")), @@ -236,6 +249,7 @@ async def build_item_response( search_id = payload["search_id"] search_type = payload["search_type"] + search_query = payload.get("search_query") assert ( search_type is not None ) # async_browse_media will not call this function if search_type is None @@ -252,6 +266,7 @@ async def build_item_response( browse_data.media_type_to_squeezebox[search_type], limit=browse_limit, browse_id=browse_id, + search_query=search_query, ) if result is not None and result.get("items"): @@ -261,7 +276,7 @@ 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 in ["favorites", "favorite"]: child_media = _build_response_favorites(item) elif search_type in ["apps", "radios"]: @@ -315,7 +330,14 @@ async def build_item_response( children.append(child_media) if children is None: - raise BrowseError(f"Media not found: {search_type} / {search_id}") + raise BrowseError( + translation_domain=DOMAIN, + translation_key="browse_media_not_found", + translation_placeholders={ + "type": str(search_type), + "id": str(search_id), + }, + ) assert media_class["item"] is not None if not search_id: @@ -398,7 +420,13 @@ async def generate_playlist( media_id = payload["search_id"] if media_type not in browse_media.squeezebox_id_by_type: - raise BrowseError(f"Media type not supported: {media_type}") + raise BrowseError( + translation_domain=DOMAIN, + translation_key="browse_media_type_not_supported", + translation_placeholders={ + "media_type": str(media_type), + }, + ) browse_id = (browse_media.squeezebox_id_by_type[media_type], media_id) if media_type.startswith("app-"): @@ -412,4 +440,11 @@ async def generate_playlist( if result and "items" in result: items: list = result["items"] return items - raise BrowseError(f"Media not found: {media_type} / {media_id}") + raise BrowseError( + translation_domain=DOMAIN, + translation_key="browse_media_not_found", + translation_placeholders={ + "type": str(media_type), + "id": str(media_id), + }, + ) diff --git a/homeassistant/components/squeezebox/button.py b/homeassistant/components/squeezebox/button.py index 098df3a1b5c..88018e4f9a9 100644 --- a/homeassistant/components/squeezebox/button.py +++ b/homeassistant/components/squeezebox/button.py @@ -18,6 +18,9 @@ from .entity import SqueezeboxEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + HARDWARE_MODELS_WITH_SCREEN = [ "Squeezebox Boom", "Squeezebox Radio", @@ -150,6 +153,11 @@ class SqueezeboxButtonEntity(SqueezeboxEntity, ButtonEntity): f"{format_mac(self._player.player_id)}_{entity_description.key}" ) + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.available and super().available + async def async_press(self) -> None: """Execute the button action.""" await self._player.async_query("button", self.entity_description.press_action) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 5ce95d25632..92eb3736341 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -13,8 +13,6 @@ SERVER_MODEL = "Lyrion Music Server" STATUS_API_TIMEOUT = 10 STATUS_SENSOR_LASTSCAN = "lastscan" STATUS_SENSOR_NEEDSRESTART = "needsrestart" -STATUS_SENSOR_NEWVERSION = "newversion" -STATUS_SENSOR_NEWPLUGINS = "newplugins" STATUS_SENSOR_RESCAN = "rescan" STATUS_SENSOR_INFO_TOTAL_ALBUMS = "info total albums" STATUS_SENSOR_INFO_TOTAL_ARTISTS = "info total artists" @@ -27,6 +25,8 @@ STATUS_QUERY_LIBRARYNAME = "libraryname" STATUS_QUERY_MAC = "mac" STATUS_QUERY_UUID = "uuid" STATUS_QUERY_VERSION = "version" +STATUS_UPDATE_NEWVERSION = "newversion" +STATUS_UPDATE_NEWPLUGINS = "newplugins" SQUEEZEBOX_SOURCE_STRINGS = ( "source:", "wavin:", @@ -44,3 +44,13 @@ DEFAULT_VOLUME_STEP = 5 ATTR_ANNOUNCE_VOLUME = "announce_volume" ATTR_ANNOUNCE_TIMEOUT = "announce_timeout" UNPLAYABLE_TYPES = ("text", "actions") +ATTR_ALARM_ID = "alarm_id" +ATTR_DAYS_OF_WEEK = "dow" +ATTR_ENABLED = "enabled" +ATTR_REPEAT = "repeat" +ATTR_SCHEDULED_TODAY = "scheduled_today" +ATTR_TIME = "time" +ATTR_VOLUME = "volume" +ATTR_URL = "url" +UPDATE_PLUGINS_RELEASE_SUMMARY = "update_plugins_release_summary" +UPDATE_RELEASE_SUMMARY = "update_release_summary" diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 955e2896947..6582f143e79 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -6,27 +6,25 @@ from asyncio import timeout from collections.abc import Callable from datetime import timedelta import logging -import re from typing import TYPE_CHECKING, Any from pysqueezebox import Player, Server +from pysqueezebox.player import Alarm from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from . import SqueezeboxConfigEntry from .const import ( + DOMAIN, PLAYER_UPDATE_INTERVAL, SENSOR_UPDATE_INTERVAL, SIGNAL_PLAYER_REDISCOVERED, STATUS_API_TIMEOUT, - STATUS_SENSOR_LASTSCAN, - STATUS_SENSOR_NEEDSRESTART, - STATUS_SENSOR_RESCAN, ) _LOGGER = logging.getLogger(__name__) @@ -50,7 +48,16 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): always_update=False, ) self.lms = lms - self.newversion_regex = re.compile("<.*$") + self.can_server_restart = False + + async def _async_setup(self) -> None: + """Query LMS capabilities.""" + result = await self.lms.async_query("can", "restartserver", "?") + if result and "_can" in result and result["_can"] == 1: + _LOGGER.debug("Can restart %s", self.lms.name) + self.can_server_restart = True + else: + _LOGGER.warning("Can't query server capabilities %s", self.lms.name) async def _async_update_data(self) -> dict: """Fetch data from LMS status call. @@ -58,32 +65,15 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): Then we process only a subset to make then nice for HA """ async with timeout(STATUS_API_TIMEOUT): - data = await self.lms.async_status() + data: dict | None = await self.lms.async_prepared_status() if not data: - raise UpdateFailed("No data from status poll") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="coordinator_no_data", + ) _LOGGER.debug("Raw serverstatus %s=%s", self.lms.name, data) - return self._prepare_status_data(data) - - def _prepare_status_data(self, data: dict) -> dict: - """Sensors that need the data changing for HA presentation.""" - - # Binary sensors - # rescan bool are we rescanning alter poll not present if false - data[STATUS_SENSOR_RESCAN] = STATUS_SENSOR_RESCAN in data - # needsrestart bool pending lms plugin updates not present if false - data[STATUS_SENSOR_NEEDSRESTART] = STATUS_SENSOR_NEEDSRESTART in data - - # Sensors that need special handling - # 'lastscan': '1718431678', epoc -> ISO 8601 not always present - data[STATUS_SENSOR_LASTSCAN] = ( - dt_util.utc_from_timestamp(int(data[STATUS_SENSOR_LASTSCAN])) - if STATUS_SENSOR_LASTSCAN in data - else None - ) - - _LOGGER.debug("Processed serverstatus %s=%s", self.lms.name, data) return data @@ -110,30 +100,39 @@ class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.player = player self.available = True + self.known_alarms: set[str] = set() self._remove_dispatcher: Callable | None = None + self.player_uuid = format_mac(player.player_id) self.server_uuid = server_uuid async def _async_update_data(self) -> dict[str, Any]: - """Update Player if available, or listen for rediscovery if not.""" + """Update the Player() object if available, or listen for rediscovery if not.""" if self.available: # Only update players available at last update, unavailable players are rediscovered instead await self.player.async_update() if self.player.connected is False: - _LOGGER.debug("Player %s is not available", self.name) + _LOGGER.info("Player %s is not available", self.name) self.available = False # start listening for restored players self._remove_dispatcher = async_dispatcher_connect( self.hass, SIGNAL_PLAYER_REDISCOVERED, self.rediscovered ) - return {} + + alarm_dict: dict[str, Alarm] = ( + {alarm["id"]: alarm for alarm in self.player.alarms} + if self.player.alarms + else {} + ) + + return {"alarms": alarm_dict} @callback def rediscovered(self, unique_id: str, connected: bool) -> None: """Make a player available again.""" if unique_id == self.player.player_id and connected: self.available = True - _LOGGER.debug("Player %s is available again", self.name) + _LOGGER.info("Player %s is available again", self.name) if self._remove_dispatcher: self._remove_dispatcher() diff --git a/homeassistant/components/squeezebox/icons.json b/homeassistant/components/squeezebox/icons.json index 29911ddad77..06779ea5e60 100644 --- a/homeassistant/components/squeezebox/icons.json +++ b/homeassistant/components/squeezebox/icons.json @@ -19,6 +19,22 @@ "other_player_count": { "default": "mdi:folder-play-outline" } + }, + "switch": { + "alarms_enabled": { + "default": "mdi:alarm-check", + "state": { + "on": "mdi:alarm-check", + "off": "mdi:alarm-off" + } + }, + "alarm": { + "default": "mdi:alarm", + "state": { + "on": "mdi:alarm", + "off": "mdi:alarm-off" + } + } } }, "services": { diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index d90e24affbb..1e803c0e1ef 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -23,6 +23,8 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, async_process_play_media_url, ) from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY @@ -75,6 +77,7 @@ ATTR_QUERY_RESULT = "query_result" _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 ATTR_PARAMETERS = "parameters" ATTR_OTHER_PLAYER = "other_player" @@ -203,6 +206,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.SEARCH_MEDIA ) _attr_has_entity_name = True _attr_name = None @@ -470,7 +474,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): if announce: if media_type not in MediaType.MUSIC: raise ServiceValidationError( - "Announcements must have media type of 'music'. Playlists are not supported" + translation_domain=DOMAIN, + translation_key="invalid_announce_media_type", + translation_placeholders={ + "media_type": str(media_type), + }, ) extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) @@ -479,7 +487,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): announce_volume = get_announce_volume(extra) except ValueError: raise ServiceValidationError( - f"{ATTR_ANNOUNCE_VOLUME} must be a number greater than 0 and less than or equal to 1" + translation_domain=DOMAIN, + translation_key="invalid_announce_volume", + translation_placeholders={ + "announce_volume": ATTR_ANNOUNCE_VOLUME, + }, ) from None else: self._player.set_announce_volume(announce_volume) @@ -488,7 +500,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): announce_timeout = get_announce_timeout(extra) except ValueError: raise ServiceValidationError( - f"{ATTR_ANNOUNCE_TIMEOUT} must be a whole number greater than 0" + translation_domain=DOMAIN, + translation_key="invalid_announce_timeout", + translation_placeholders={ + "announce_timeout": ATTR_ANNOUNCE_TIMEOUT, + }, ) from None else: self._player.set_announce_timeout(announce_timeout) @@ -532,6 +548,74 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): await self._player.async_index(index) await self.coordinator.async_refresh() + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the media player.""" + + _valid_type_list = [ + key + for key in self._browse_data.content_type_media_class + if key not in ["apps", "app", "radios", "radio"] + ] + + _media_content_type_list = ( + query.media_content_type.lower().replace(", ", ",").split(",") + if query.media_content_type + else ["albums", "tracks", "artists", "genres"] + ) + + if query.media_content_type and set(_media_content_type_list).difference( + _valid_type_list + ): + _LOGGER.debug("Invalid Media Content Type: %s", query.media_content_type) + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_search_media_content_type", + translation_placeholders={ + "media_content_type": ", ".join(_valid_type_list) + }, + ) + + search_response_list: list[BrowseMedia] = [] + + for _content_type in _media_content_type_list: + payload = { + "search_type": _content_type, + "search_id": query.media_content_id, + "search_query": query.search_query, + } + + try: + search_response_list.append( + await build_item_response( + self, + self._player, + payload, + self.browse_limit, + self._browse_data, + ) + ) + except BrowseError: + _LOGGER.debug("Search Failure: Payload %s", payload) + + result: list[BrowseMedia] = [] + + for search_response in search_response_list: + # Apply the media_filter_classes to the result if specified + if query.media_filter_classes and search_response.children: + search_response.children = [ + child + for child in search_response.children + if child.media_content_type in query.media_filter_classes + ] + if search_response.children: + result.extend(list(search_response.children)) + + return SearchMedia(result=result) + async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set the repeat mode.""" if repeat == RepeatMode.ALL: @@ -594,13 +678,21 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): other_player = ent_reg.async_get(other_player_entity_id) if other_player is None: raise ServiceValidationError( - f"Could not find player with entity_id {other_player_entity_id}" + translation_domain=DOMAIN, + translation_key="join_cannot_find_other_player", + translation_placeholders={ + "other_player_entity_id": str(other_player_entity_id) + }, ) if other_player_id := other_player.unique_id: await self._player.async_sync(other_player_id) else: raise ServiceValidationError( - f"Could not join unknown player {other_player_entity_id}" + translation_domain=DOMAIN, + translation_key="join_cannot_join_unknown_player", + translation_placeholders={ + "other_player_entity_id": str(other_player_entity_id) + }, ) async def async_unjoin_player(self) -> None: diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index 9d9490208ea..11c169910dc 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -29,6 +29,9 @@ from .const import ( ) from .entity import LMSStatusEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_ALBUMS, diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 83c5d7dd5d0..59d426047de 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -17,7 +17,14 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "https": "Connect over https (requires reverse proxy)" + "https": "Connect over HTTPS (requires reverse proxy)" + }, + "data_description": { + "host": "[%key:component::squeezebox::config::step::user::data_description::host%]", + "port": "The web interface port on the LMS. The default is 9000.", + "username": "The username from LMS Advanced Security (if defined).", + "password": "The password from LMS Advanced Security (if defined).", + "https": "Connect to the LMS over HTTPS (requires reverse proxy)." } } }, @@ -29,7 +36,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_server_found": "No LMS server found." + "no_server_found": "No LMS found." } }, "services": { @@ -125,6 +132,22 @@ "name": "Player count off service", "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]" } + }, + "switch": { + "alarm": { + "name": "Alarm ({alarm_id})" + }, + "alarms_enabled": { + "name": "Alarms enabled" + } + }, + "update": { + "newversion": { + "name": "Lyrion Music Server" + }, + "newplugins": { + "name": "Updated plugins" + } } }, "options": { @@ -141,5 +164,49 @@ } } } + }, + "exceptions": { + "init_timeout": { + "message": "Timeout connecting to LMS {host}." + }, + "init_auth_failed": { + "message": "Authentication failed with {host}." + }, + "init_get_status_failed": { + "message": "Failed to get status from LMS {host} (HTTP status: {http_status}). Will retry." + }, + "init_missing_uuid": { + "message": "LMS {host} status response missing essential data (UUID)." + }, + "invalid_announce_media_type": { + "message": "Only type 'music' can be played as announcement (received type {media_type})." + }, + "invalid_announce_volume": { + "message": "{announce_volume} must be a number greater than 0 and less than or equal to 1." + }, + "invalid_announce_timeout": { + "message": "{announce_timeout} must be a number greater than 0." + }, + "join_cannot_find_other_player": { + "message": "Could not find player with entity_id {other_player_entity_id}." + }, + "join_cannot_join_unknown_player": { + "message": "Could not join unknown player {other_player_entity_id}." + }, + "coordinator_no_data": { + "message": "No data from status poll." + }, + "browse_media_not_found": { + "message": "Media not found: {type} / {id}." + }, + "browse_media_type_not_supported": { + "message": "Media type not supported: {media_type}." + }, + "update_restart_failed": { + "message": "Error trying to update LMS Plugins: Restart failed." + }, + "invalid_search_media_content_type": { + "message": "If specified, Media content type must be one of {media_content_type}" + } } } diff --git a/homeassistant/components/squeezebox/switch.py b/homeassistant/components/squeezebox/switch.py new file mode 100644 index 00000000000..33926c53e64 --- /dev/null +++ b/homeassistant/components/squeezebox/switch.py @@ -0,0 +1,185 @@ +"""Switch entity representing a Squeezebox alarm.""" + +import datetime +import logging +from typing import Any, cast + +from pysqueezebox.player import Alarm + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_time_change + +from .const import ATTR_ALARM_ID, DOMAIN, SIGNAL_PLAYER_DISCOVERED +from .coordinator import SqueezeBoxPlayerUpdateCoordinator +from .entity import SqueezeboxEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Squeezebox alarm switch.""" + + async def _player_discovered( + coordinator: SqueezeBoxPlayerUpdateCoordinator, + ) -> None: + def _async_listener() -> None: + """Handle alarm creation and deletion after coordinator data update.""" + new_alarms: set[str] = set() + received_alarms: set[str] = set() + + if coordinator.data["alarms"] and coordinator.available: + received_alarms = set(coordinator.data["alarms"]) + new_alarms = received_alarms - coordinator.known_alarms + removed_alarms = coordinator.known_alarms - received_alarms + + if new_alarms: + for new_alarm in new_alarms: + coordinator.known_alarms.add(new_alarm) + _LOGGER.debug( + "Setting up alarm entity for alarm %s on player %s", + new_alarm, + coordinator.player, + ) + async_add_entities([SqueezeBoxAlarmEntity(coordinator, new_alarm)]) + + if removed_alarms and coordinator.available: + for removed_alarm in removed_alarms: + _uid = f"{coordinator.player_uuid}_alarm_{removed_alarm}" + _LOGGER.debug( + "Alarm %s with unique_id %s needs to be deleted", + removed_alarm, + _uid, + ) + + entity_registry = er.async_get(hass) + _entity_id = entity_registry.async_get_entity_id( + Platform.SWITCH, + DOMAIN, + _uid, + ) + if _entity_id: + entity_registry.async_remove(_entity_id) + coordinator.known_alarms.remove(removed_alarm) + + _LOGGER.debug( + "Setting up alarm enabled entity for player %s", coordinator.player + ) + # Add listener first for future coordinator refresh + coordinator.async_add_listener(_async_listener) + + # If coordinator already has alarm data from the initial refresh, + # call the listener immediately to process existing alarms and create alarm entities. + if coordinator.data["alarms"]: + _LOGGER.debug( + "Coordinator has alarm data, calling _async_listener immediately for player %s", + coordinator.player, + ) + _async_listener() + async_add_entities([SqueezeBoxAlarmsEnabledEntity(coordinator)]) + + entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered) + ) + + +class SqueezeBoxAlarmEntity(SqueezeboxEntity, SwitchEntity): + """Representation of a Squeezebox alarm switch.""" + + _attr_translation_key = "alarm" + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, coordinator: SqueezeBoxPlayerUpdateCoordinator, alarm_id: str + ) -> None: + """Initialize the Squeezebox alarm switch.""" + super().__init__(coordinator) + self._alarm_id = alarm_id + self._attr_translation_placeholders = {"alarm_id": self._alarm_id} + self._attr_unique_id: str = ( + f"{format_mac(self._player.player_id)}_alarm_{self._alarm_id}" + ) + + async def async_added_to_hass(self) -> None: + """Set up alarm switch when added to hass.""" + await super().async_added_to_hass() + + async def async_write_state_daily(now: datetime.datetime) -> None: + """Update alarm state attributes each calendar day.""" + _LOGGER.debug("Updating state attributes for %s", self.name) + self.async_write_ha_state() + + self.async_on_remove( + async_track_time_change( + self.hass, async_write_state_daily, hour=0, minute=0, second=0 + ) + ) + + @property + def alarm(self) -> Alarm: + """Return the alarm object.""" + return self.coordinator.data["alarms"][self._alarm_id] + + @property + def available(self) -> bool: + """Return whether the alarm is available.""" + return super().available and self._alarm_id in self.coordinator.data["alarms"] + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return attributes of Squeezebox alarm switch.""" + return {ATTR_ALARM_ID: str(self._alarm_id)} + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return cast(bool, self.alarm["enabled"]) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.coordinator.player.async_update_alarm(self._alarm_id, enabled=False) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.coordinator.player.async_update_alarm(self._alarm_id, enabled=True) + await self.coordinator.async_request_refresh() + + +class SqueezeBoxAlarmsEnabledEntity(SqueezeboxEntity, SwitchEntity): + """Representation of a Squeezebox players alarms enabled master switch.""" + + _attr_translation_key = "alarms_enabled" + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: + """Initialize the Squeezebox alarm switch.""" + super().__init__(coordinator) + self._attr_unique_id: str = ( + f"{format_mac(self._player.player_id)}_alarms_enabled" + ) + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return cast(bool, self.coordinator.player.alarms_enabled) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.coordinator.player.async_set_alarms_enabled(False) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.coordinator.player.async_set_alarms_enabled(True) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/squeezebox/update.py b/homeassistant/components/squeezebox/update.py new file mode 100644 index 00000000000..62579424d25 --- /dev/null +++ b/homeassistant/components/squeezebox/update.py @@ -0,0 +1,175 @@ +"""Platform for update integration for squeezebox.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +import logging +from typing import Any + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later + +from . import SqueezeboxConfigEntry +from .const import ( + DOMAIN, + SERVER_MODEL, + STATUS_QUERY_VERSION, + STATUS_UPDATE_NEWPLUGINS, + STATUS_UPDATE_NEWVERSION, + UPDATE_PLUGINS_RELEASE_SUMMARY, + UPDATE_RELEASE_SUMMARY, +) +from .entity import LMSStatusEntity + +newserver = UpdateEntityDescription( + key=STATUS_UPDATE_NEWVERSION, +) + +newplugins = UpdateEntityDescription( + key=STATUS_UPDATE_NEWPLUGINS, +) + +POLL_AFTER_INSTALL = 120 + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SqueezeboxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Platform setup using common elements.""" + + async_add_entities( + [ + ServerStatusUpdateLMS(entry.runtime_data.coordinator, newserver), + ServerStatusUpdatePlugins(entry.runtime_data.coordinator, newplugins), + ] + ) + + +class ServerStatusUpdate(LMSStatusEntity, UpdateEntity): + """LMS Status update sensors via cooridnatior.""" + + @property + def latest_version(self) -> str: + """LMS Status directly from coordinator data.""" + return str(self.coordinator.data[self.entity_description.key]) + + +class ServerStatusUpdateLMS(ServerStatusUpdate): + """LMS Status update sensor from LMS via cooridnatior.""" + + title: str = SERVER_MODEL + + @property + def installed_version(self) -> str: + """LMS Status directly from coordinator data.""" + return str(self.coordinator.data[STATUS_QUERY_VERSION]) + + @property + def release_url(self) -> str: + """LMS Update info page.""" + return str(self.coordinator.lms.generate_image_url("updateinfo.html")) + + @property + def release_summary(self) -> None | str: + """If install is supported give some info.""" + return ( + str(self.coordinator.data[UPDATE_RELEASE_SUMMARY]) + if self.coordinator.data[UPDATE_RELEASE_SUMMARY] + else None + ) + + +class ServerStatusUpdatePlugins(ServerStatusUpdate): + """LMS Plugings update sensor from LMS via cooridnatior.""" + + auto_update = True + title: str = SERVER_MODEL + " Plugins" + installed_version = "Current" + restart_triggered = False + _cancel_update: Callable | None = None + + @property + def supported_features(self) -> UpdateEntityFeature: + """Support install if we can.""" + return ( + (UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS) + if self.coordinator.can_server_restart + else UpdateEntityFeature(0) + ) + + @property + def release_summary(self) -> None | str: + """If install is supported give some info.""" + rs = self.coordinator.data[UPDATE_PLUGINS_RELEASE_SUMMARY] + return ( + (rs or "") + + "The Plugins will be updated on the next restart triggred by selecting the Install button. Allow enough time for the service to restart. It will become briefly unavailable." + if self.coordinator.can_server_restart + else rs + ) + + @property + def release_url(self) -> str: + """LMS Plugins info page.""" + return str( + self.coordinator.lms.generate_image_url( + "/settings/index.html?activePage=SETUP_PLUGINS" + ) + ) + + @property + def in_progress(self) -> bool: + """Are we restarting.""" + if self.latest_version == self.installed_version and self.restart_triggered: + _LOGGER.debug("plugin progress reset %s", self.coordinator.lms.name) + if callable(self._cancel_update): + self._cancel_update() + self.restart_triggered = False + return self.restart_triggered + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install all plugin updates.""" + _LOGGER.debug( + "server restart for plugin install on %s", self.coordinator.lms.name + ) + self.restart_triggered = True + self.async_write_ha_state() + + result = await self.coordinator.lms.async_query("restartserver") + _LOGGER.debug("restart server result %s", result) + if not result: + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_catchall + ) + else: + self.restart_triggered = False + self.async_write_ha_state() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_restart_failed", + ) + + async def _async_update_catchall(self, now: datetime | None = None) -> None: + """Request update. clear restart catchall.""" + if self.restart_triggered: + _LOGGER.debug("server restart catchall for %s", self.coordinator.lms.name) + self.restart_triggered = False + self.async_write_ha_state() + await self.async_update() diff --git a/homeassistant/components/ssdp/scanner.py b/homeassistant/components/ssdp/scanner.py index d42c879e76a..1b7d69a3214 100644 --- a/homeassistant/components/ssdp/scanner.py +++ b/homeassistant/components/ssdp/scanner.py @@ -260,11 +260,12 @@ class Scanner: for source_ip in await async_build_source_set(self.hass): source_ip_str = str(source_ip) if source_ip.version == 6: + assert source_ip.scope_id is not None source_tuple: AddressTupleVXType = ( source_ip_str, 0, 0, - int(getattr(source_ip, "scope_id")), + int(source_ip.scope_id), ) else: source_tuple = (source_ip_str, 0) diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py index 6d89263ab20..3a164fa374b 100644 --- a/homeassistant/components/ssdp/server.py +++ b/homeassistant/components/ssdp/server.py @@ -170,11 +170,12 @@ class Server: for source_ip in await async_build_source_set(self.hass): source_ip_str = str(source_ip) if source_ip.version == 6: + assert source_ip.scope_id is not None source_tuple: AddressTupleVXType = ( source_ip_str, 0, 0, - int(getattr(source_ip, "scope_id")), + int(source_ip.scope_id), ) else: source_tuple = (source_ip_str, 0) diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py index fa46d2a3773..fd449607f52 100644 --- a/homeassistant/components/starline/button.py +++ b/homeassistant/components/starline/button.py @@ -64,10 +64,10 @@ class StarlineButton(StarlineEntity, ButtonEntity): self.entity_description = description @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return super().available and self._device.online - def press(self): + def press(self) -> None: """Press the button.""" self._account.api.set_car_state(self._device.device_id, self._key, True) diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py index 0c8418d28fc..d6e12b4ecd9 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -1,5 +1,7 @@ """StarLine device tracker.""" +from typing import Any + from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -35,26 +37,26 @@ class StarlineDeviceTracker(StarlineEntity, TrackerEntity, RestoreEntity): super().__init__(account, device, "location") @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific attributes.""" return self._account.gps_attrs(self._device) @property - def battery_level(self): + def battery_level(self) -> int | None: """Return the battery level of the device.""" return self._device.battery_level @property - def location_accuracy(self): + def location_accuracy(self) -> float: """Return the gps accuracy of the device.""" return self._device.position.get("r", 0) @property - def latitude(self): + def latitude(self) -> float: """Return latitude value of the device.""" return self._device.position["x"] @property - def longitude(self): + def longitude(self) -> float: """Return longitude value of the device.""" return self._device.position["y"] diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index 15bad3ebc2e..cc787076e7a 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", - "requirements": ["starlink-grpc-core==1.2.2"] + "requirements": ["starlink-grpc-core==1.2.3"] } diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index f71274e0ee7..f800c82f1f9 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -4,8 +4,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes DOMAIN = "statistics" PLATFORMS = [Platform.SENSOR] @@ -20,6 +22,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_ENTITY_ID], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we remove the config entry because + # statistics does not allow replacing the input entity. + await hass.config_entries.async_remove(entry.entry_id) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_ENTITY_ID] + ), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index 4c78afbde9c..fb8c09868d5 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -106,6 +106,19 @@ DATA_SCHEMA_SETUP = vol.Schema( ) DATA_SCHEMA_OPTIONS = vol.Schema( { + vol.Optional(CONF_ENTITY_ID): EntitySelector( + EntitySelectorConfig(read_only=True) + ), + vol.Optional(CONF_STATE_CHARACTERISTIC): SelectSelector( + SelectSelectorConfig( + options=list( + set(list(STATS_BINARY_SUPPORT) + list(STATS_NUMERIC_SUPPORT)) + ), + translation_key=CONF_STATE_CHARACTERISTIC, + mode=SelectSelectorMode.DROPDOWN, + read_only=True, + ) + ), vol.Optional(CONF_SAMPLES_MAX_BUFFER_SIZE): NumberSelector( NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) ), diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json index e1085a016ce..e0093fd08c8 100644 --- a/homeassistant/components/statistics/strings.json +++ b/homeassistant/components/statistics/strings.json @@ -32,6 +32,8 @@ "options": { "description": "Read the documentation for further details on how to configure the statistics sensor using these options.", "data": { + "entity_id": "[%key:component::statistics::config::step::user::data::entity_id%]", + "state_characteristic": "[%key:component::statistics::config::step::state_characteristic::data::state_characteristic%]", "sampling_size": "Sampling size", "max_age": "Max age", "keep_last_sample": "Keep last sample", @@ -39,6 +41,8 @@ "precision": "Precision" }, "data_description": { + "entity_id": "[%key:component::statistics::config::step::user::data_description::entity_id%]", + "state_characteristic": "[%key:component::statistics::config::step::state_characteristic::data_description::state_characteristic%]", "sampling_size": "Maximum number of source sensor measurements stored.", "max_age": "Maximum age of source sensor measurements stored.", "keep_last_sample": "Defines whether the most recent sampled value should be preserved regardless of the 'Max age' setting.", @@ -60,6 +64,8 @@ "init": { "description": "[%key:component::statistics::config::step::options::description%]", "data": { + "entity_id": "[%key:component::statistics::config::step::user::data::entity_id%]", + "state_characteristic": "[%key:component::statistics::config::step::state_characteristic::data::state_characteristic%]", "sampling_size": "[%key:component::statistics::config::step::options::data::sampling_size%]", "max_age": "[%key:component::statistics::config::step::options::data::max_age%]", "keep_last_sample": "[%key:component::statistics::config::step::options::data::keep_last_sample%]", @@ -67,6 +73,8 @@ "precision": "[%key:component::statistics::config::step::options::data::precision%]" }, "data_description": { + "entity_id": "[%key:component::statistics::config::step::user::data_description::entity_id%]", + "state_characteristic": "[%key:component::statistics::config::step::state_characteristic::data_description::state_characteristic%]", "sampling_size": "[%key:component::statistics::config::step::options::data_description::sampling_size%]", "max_age": "[%key:component::statistics::config::step::options::data_description::max_age%]", "keep_last_sample": "[%key:component::statistics::config::step::options::data_description::keep_last_sample%]", diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 7525e73f802..6aef0041874 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -12,7 +12,7 @@ }, "two_factor": { "title": "[%key:component::subaru::config::step::user::title%]", - "description": "Two factor authentication required", + "description": "Two-factor authentication required", "data": { "contact_method": "Please select a contact method:" } diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 10d4d3cdbcb..83283ae8ec5 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -1,18 +1,35 @@ """Suez water update coordinator.""" from dataclasses import dataclass -from datetime import date +from datetime import date, datetime +import logging -from pysuez import PySuezError, SuezClient +from pysuez import PySuezError, SuezClient, TelemetryMeasure +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + StatisticMeanType, + StatisticsRow, + async_add_external_statistics, + get_last_statistics, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import _LOGGER, HomeAssistant +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + CURRENCY_EURO, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN +_LOGGER = logging.getLogger(__name__) + @dataclass class SuezWaterAggregatedAttributes: @@ -32,7 +49,7 @@ class SuezWaterData: aggregated_value: float aggregated_attr: SuezWaterAggregatedAttributes - price: float + price: float | None type SuezWaterConfigEntry = ConfigEntry[SuezWaterCoordinator] @@ -54,6 +71,11 @@ class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]): always_update=True, config_entry=config_entry, ) + self._counter_id = self.config_entry.data[CONF_COUNTER_ID] + self._cost_statistic_id = f"{DOMAIN}:{self._counter_id}_water_cost_statistics" + self._water_statistic_id = ( + f"{DOMAIN}:{self._counter_id}_water_consumption_statistics" + ) async def _async_setup(self) -> None: self._suez_client = SuezClient( @@ -72,19 +94,165 @@ class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]): try: aggregated = await self._suez_client.fetch_aggregated_data() - data = SuezWaterData( - aggregated_value=aggregated.value, - aggregated_attr=SuezWaterAggregatedAttributes( - this_month_consumption=map_dict(aggregated.current_month), - previous_month_consumption=map_dict(aggregated.previous_month), - highest_monthly_consumption=aggregated.highest_monthly_consumption, - last_year_overall=aggregated.previous_year, - this_year_overall=aggregated.current_year, - history=map_dict(aggregated.history), - ), - price=(await self._suez_client.get_price()).price, - ) except PySuezError as err: - raise UpdateFailed(f"Suez data update failed: {err}") from err + raise UpdateFailed("Suez coordinator error communicating with API") from err + + price = None + try: + price = (await self._suez_client.get_price()).price + except PySuezError: + _LOGGER.debug("Failed to fetch water price", stack_info=True) + + try: + await self._update_statistics(price) + except PySuezError as err: + raise UpdateFailed("Failed to update suez water statistics") from err + _LOGGER.debug("Successfully fetched suez data") - return data + return SuezWaterData( + aggregated_value=aggregated.value, + aggregated_attr=SuezWaterAggregatedAttributes( + this_month_consumption=map_dict(aggregated.current_month), + previous_month_consumption=map_dict(aggregated.previous_month), + highest_monthly_consumption=aggregated.highest_monthly_consumption, + last_year_overall=aggregated.previous_year, + this_year_overall=aggregated.current_year, + history=map_dict(aggregated.history), + ), + price=price, + ) + + async def _update_statistics(self, current_price: float | None) -> None: + """Update daily statistics.""" + _LOGGER.debug("Updating statistics for %s", self._water_statistic_id) + + water_last_stat = await self._get_last_stat(self._water_statistic_id) + cost_last_stat = await self._get_last_stat(self._cost_statistic_id) + consumption_sum = ( + water_last_stat["sum"] + if water_last_stat and water_last_stat["sum"] + else 0.0 + ) + cost_sum = ( + cost_last_stat["sum"] if cost_last_stat and cost_last_stat["sum"] else 0.0 + ) + last_stats = ( + datetime.fromtimestamp(water_last_stat["start"]).date() + if water_last_stat + else None + ) + + _LOGGER.debug( + "Updating suez stat since %s for %s", + str(last_stats), + water_last_stat, + ) + if not ( + usage := await self._suez_client.fetch_all_daily_data( + since=last_stats, + ) + ): + _LOGGER.debug("No recent usage data. Skipping update") + return + _LOGGER.debug("fetched data: %s", len(usage)) + + consumption_statistics, cost_statistics = self._build_statistics( + current_price, consumption_sum, cost_sum, last_stats, usage + ) + + self._persist_statistics(consumption_statistics, cost_statistics) + + def _build_statistics( + self, + current_price: float | None, + consumption_sum: float, + cost_sum: float, + last_stats: date | None, + usage: list[TelemetryMeasure], + ) -> tuple[list[StatisticData], list[StatisticData]]: + """Build statistics data from fetched data.""" + consumption_statistics = [] + cost_statistics = [] + + for data in usage: + if ( + (last_stats is not None and data.date <= last_stats) + or not data.index + or data.volume is None + ): + continue + consumption_date = dt_util.start_of_local_day(data.date) + + consumption_sum += data.volume + consumption_statistics.append( + StatisticData( + start=consumption_date, + state=data.volume, + sum=consumption_sum, + ) + ) + if current_price is not None: + day_cost = (data.volume / 1000) * current_price + cost_sum += day_cost + cost_statistics.append( + StatisticData( + start=consumption_date, + state=day_cost, + sum=cost_sum, + ) + ) + + return consumption_statistics, cost_statistics + + def _persist_statistics( + self, + consumption_statistics: list[StatisticData], + cost_statistics: list[StatisticData], + ) -> None: + """Persist given statistics in recorder.""" + consumption_metadata = self._get_statistics_metadata( + id=self._water_statistic_id, name="Consumption", unit=UnitOfVolume.LITERS + ) + + _LOGGER.debug( + "Adding %s statistics for %s", + len(consumption_statistics), + self._water_statistic_id, + ) + async_add_external_statistics( + self.hass, consumption_metadata, consumption_statistics + ) + + if len(cost_statistics) > 0: + _LOGGER.debug( + "Adding %s statistics for %s", + len(cost_statistics), + self._cost_statistic_id, + ) + cost_metadata = self._get_statistics_metadata( + id=self._cost_statistic_id, name="Cost", unit=CURRENCY_EURO + ) + async_add_external_statistics(self.hass, cost_metadata, cost_statistics) + + _LOGGER.debug("Updated statistics for %s", self._water_statistic_id) + + def _get_statistics_metadata( + self, id: str, name: str, unit: str + ) -> StatisticMetaData: + """Build statistics metadata for requested configuration.""" + return StatisticMetaData( + has_mean=False, + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"Suez water {name} {self._counter_id}", + source=DOMAIN, + statistic_id=id, + unit_of_measurement=unit, + ) + + async def _get_last_stat(self, id: str) -> StatisticsRow | None: + """Find last registered statistics of given id.""" + last_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, id, True, {"sum"} + ) + return last_stat[id][0] if last_stat else None diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index f09d2e22633..9149f216563 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -1,11 +1,12 @@ { "domain": "suez_water", "name": "Suez Water", + "after_dependencies": ["recorder"], "codeowners": ["@ooii", "@jb101010-2"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.4"] + "requirements": ["pysuezV2==2.0.5"] } diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index a162cc6168d..9bbe24abb59 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -87,6 +87,14 @@ class SuezWaterSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): ) self.entity_description = entity_description + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.coordinator.last_update_success + and self.entity_description.value_fn(self.coordinator.data) is not None + ) + @property def native_value(self) -> float | str | None: """Return the state of the sensor.""" diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py new file mode 100644 index 00000000000..205f1bb8b5c --- /dev/null +++ b/homeassistant/components/sun/condition.py @@ -0,0 +1,143 @@ +"""Offer sun based automation rules.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import cast + +import voluptuous as vol + +from homeassistant.const import CONF_CONDITION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import ( + ConditionCheckerType, + condition_trace_set_result, + condition_trace_update_result, + trace_condition_function, +) +from homeassistant.helpers.sun import get_astral_event_date +from homeassistant.helpers.typing import ConfigType, TemplateVarsType +from homeassistant.util import dt as dt_util + +_CONDITION_SCHEMA = vol.All( + vol.Schema( + { + **cv.CONDITION_BASE_SCHEMA, + vol.Required(CONF_CONDITION): "sun", + vol.Optional("before"): cv.sun_event, + vol.Optional("before_offset"): cv.time_period, + vol.Optional("after"): vol.All( + vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) + ), + vol.Optional("after_offset"): cv.time_period, + } + ), + cv.has_at_least_one_key("before", "after"), +) + + +async def async_validate_condition_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + + +def sun( + hass: HomeAssistant, + before: str | None = None, + after: str | None = None, + before_offset: timedelta | None = None, + after_offset: timedelta | None = None, +) -> bool: + """Test if current time matches sun requirements.""" + utcnow = dt_util.utcnow() + today = dt_util.as_local(utcnow).date() + before_offset = before_offset or timedelta(0) + after_offset = after_offset or timedelta(0) + + sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today) + sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) + + has_sunrise_condition = SUN_EVENT_SUNRISE in (before, after) + has_sunset_condition = SUN_EVENT_SUNSET in (before, after) + + after_sunrise = today > dt_util.as_local(cast(datetime, sunrise)).date() + if after_sunrise and has_sunrise_condition: + tomorrow = today + timedelta(days=1) + sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow) + + after_sunset = today > dt_util.as_local(cast(datetime, sunset)).date() + if after_sunset and has_sunset_condition: + tomorrow = today + timedelta(days=1) + sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow) + + # Special case: before sunrise OR after sunset + # This will handle the very rare case in the polar region when the sun rises/sets + # but does not set/rise. + # However this entire condition does not handle those full days of darkness + # or light, the following should be used instead: + # + # condition: + # condition: state + # entity_id: sun.sun + # state: 'above_horizon' (or 'below_horizon') + # + if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET: + wanted_time_before = cast(datetime, sunrise) + before_offset + condition_trace_update_result(wanted_time_before=wanted_time_before) + wanted_time_after = cast(datetime, sunset) + after_offset + condition_trace_update_result(wanted_time_after=wanted_time_after) + return utcnow < wanted_time_before or utcnow > wanted_time_after + + if sunrise is None and has_sunrise_condition: + # There is no sunrise today + condition_trace_set_result(False, message="no sunrise today") + return False + + if sunset is None and has_sunset_condition: + # There is no sunset today + condition_trace_set_result(False, message="no sunset today") + return False + + if before == SUN_EVENT_SUNRISE: + wanted_time_before = cast(datetime, sunrise) + before_offset + condition_trace_update_result(wanted_time_before=wanted_time_before) + if utcnow > wanted_time_before: + return False + + if before == SUN_EVENT_SUNSET: + wanted_time_before = cast(datetime, sunset) + before_offset + condition_trace_update_result(wanted_time_before=wanted_time_before) + if utcnow > wanted_time_before: + return False + + if after == SUN_EVENT_SUNRISE: + wanted_time_after = cast(datetime, sunrise) + after_offset + condition_trace_update_result(wanted_time_after=wanted_time_after) + if utcnow < wanted_time_after: + return False + + if after == SUN_EVENT_SUNSET: + wanted_time_after = cast(datetime, sunset) + after_offset + condition_trace_update_result(wanted_time_after=wanted_time_after) + if utcnow < wanted_time_after: + return False + + return True + + +def async_condition_from_config(config: ConfigType) -> ConditionCheckerType: + """Wrap action method with sun based condition.""" + before = config.get("before") + after = config.get("after") + before_offset = config.get("before_offset") + after_offset = config.get("after_offset") + + @trace_condition_function + def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + """Validate time based if-condition.""" + return sun(hass, before, after, before_offset, after_offset) + + return sun_if diff --git a/homeassistant/components/sun/entity.py b/homeassistant/components/sun/entity.py index 925845c8b4d..4070190e52a 100644 --- a/homeassistant/components/sun/entity.py +++ b/homeassistant/components/sun/entity.py @@ -74,8 +74,8 @@ PHASE_DAY = "day" _PHASE_UPDATES = { PHASE_NIGHT: timedelta(minutes=4 * 5), PHASE_ASTRONOMICAL_TWILIGHT: timedelta(minutes=4 * 2), - PHASE_NAUTICAL_TWILIGHT: timedelta(minutes=4 * 2), - PHASE_TWILIGHT: timedelta(minutes=4), + PHASE_NAUTICAL_TWILIGHT: timedelta(minutes=4), + PHASE_TWILIGHT: timedelta(minutes=2), PHASE_SMALL_DAY: timedelta(minutes=2), PHASE_DAY: timedelta(minutes=4), } diff --git a/homeassistant/components/sun/manifest.json b/homeassistant/components/sun/manifest.json index f6b4ae1976b..b693509b27a 100644 --- a/homeassistant/components/sun/manifest.json +++ b/homeassistant/components/sun/manifest.json @@ -1,7 +1,7 @@ { "domain": "sun", "name": "Sun", - "codeowners": ["@Swamp-Ig"], + "codeowners": ["@home-assistant/core"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sun", "iot_class": "calculated", diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index c443e1e63df..c14eb6fb353 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -71,7 +71,7 @@ class SupervisorProcessSensor(SensorEntity): return self._info.get("statename") @property - def available(self): + def available(self) -> bool: """Could the device be accessed during the last update call.""" return self._available diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 71cb9e9c225..c77eda9b294 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -9,14 +9,11 @@ import voluptuous as vol from homeassistant.components.homeassistant import exposed_entities from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from .const import CONF_INVERT, CONF_TARGET_DOMAIN -from .light import LightSwitch - -__all__ = ["LightSwitch"] _LOGGER = logging.getLogger(__name__) @@ -44,10 +41,11 @@ def async_add_to_device( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - registry = er.async_get(hass) - device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) try: - entity_id = er.async_validate_entity_id(registry, entry.options[CONF_ENTITY_ID]) + entity_id = er.async_validate_entity_id( + entity_registry, entry.options[CONF_ENTITY_ID] + ) except vol.Invalid: # The entity is identified by an unknown entity registry ID _LOGGER.error( @@ -56,45 +54,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - async def async_registry_updated( - event: Event[er.EventEntityRegistryUpdatedData], - ) -> None: - """Handle entity registry update.""" - data = event.data - if data["action"] == "remove": - await hass.config_entries.async_remove(entry.entry_id) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) - if data["action"] != "update": - return - - if "entity_id" in data["changes"]: - # Entity_id changed, reload the config entry - await hass.config_entries.async_reload(entry.entry_id) - - if device_id and "device_id" in data["changes"]: - # If the tracked switch is no longer in the device, remove our config entry - # from the device - if ( - not (entity_entry := registry.async_get(data[CONF_ENTITY_ID])) - or not device_registry.async_get(device_id) - or entity_entry.device_id == device_id - ): - # No need to do any cleanup - return - - device_registry.async_update_device( - device_id, remove_config_entry_id=entry.entry_id - ) + async def source_entity_removed() -> None: + # The source entity has been removed, we remove the config entry because + # switch_as_x does not allow replacing the wrapped entity. + await hass.config_entries.async_remove(entry.entry_id) entry.async_on_unload( - async_track_entity_registry_updated_event( - hass, entity_id, async_registry_updated + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_add_to_device(hass, entry, entity_id), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, ) ) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) - device_id = async_add_to_device(hass, entry, entity_id) - await hass.config_entries.async_forward_entry_setups( entry, (entry.options[CONF_TARGET_DOMAIN],) ) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 8f417bc641a..af4001f0d9a 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -24,6 +24,7 @@ from .const import ( CONF_RETRY_COUNT, CONNECTABLE_SUPPORTED_MODEL_TYPES, DEFAULT_RETRY_COUNT, + DOMAIN, ENCRYPTED_MODELS, HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL, SupportedModels, @@ -73,6 +74,24 @@ PLATFORMS_BY_TYPE = { ], SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR], + SupportedModels.K20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.K10_PRO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.K10_PRO_COMBO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.HUB3.value: [Platform.SENSOR, Platform.BINARY_SENSOR], + SupportedModels.LOCK_LITE.value: [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + ], + SupportedModels.LOCK_ULTRA.value: [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + ], + SupportedModels.AIR_PURIFIER.value: [Platform.FAN, Platform.SENSOR], + SupportedModels.AIR_PURIFIER_TABLE.value: [Platform.FAN, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -89,6 +108,15 @@ CLASS_BY_DEVICE = { SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch, SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade, SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan, + SupportedModels.K20_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.K10_PRO_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.LOCK_LITE.value: switchbot.SwitchbotLock, + SupportedModels.LOCK_ULTRA.value: switchbot.SwitchbotLock, + SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier, + SupportedModels.AIR_PURIFIER_TABLE.value: switchbot.SwitchbotAirPurifier, } @@ -128,7 +156,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> ) if not ble_device: raise ConfigEntryNotReady( - f"Could not find Switchbot {sensor_type} with address {address}" + translation_domain=DOMAIN, + translation_key="device_not_found_error", + translation_placeholders={"sensor_type": sensor_type, "address": address}, ) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) @@ -143,7 +173,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> ) except ValueError as error: raise ConfigEntryNotReady( - "Invalid encryption configuration provided" + translation_domain=DOMAIN, + translation_key="value_error", + translation_placeholders={"error": str(error)}, ) from error else: device = cls( @@ -164,7 +196,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> ) entry.async_on_unload(coordinator.async_start()) if not await coordinator.async_wait_ready(): - raise ConfigEntryNotReady(f"{address} is not advertising state") + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="advertising_state_error", + translation_placeholders={"address": address}, + ) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups( diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 04b4e20b7ce..82e6e43130b 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -367,7 +367,9 @@ class SwitchbotOptionsFlowHandler(OptionsFlow): ), ): int } - if self.config_entry.data.get(CONF_SENSOR_TYPE) == SupportedModels.LOCK_PRO: + if self.config_entry.data.get(CONF_SENSOR_TYPE, "").startswith( + SupportedModels.LOCK + ): options.update( { vol.Optional( diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 41bbb247929..f6536ca3ff3 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -38,6 +38,16 @@ class SupportedModels(StrEnum): ROLLER_SHADE = "roller_shade" HUBMINI_MATTER = "hubmini_matter" CIRCULATOR_FAN = "circulator_fan" + K20_VACUUM = "k20_vacuum" + S10_VACUUM = "s10_vacuum" + K10_VACUUM = "k10_vacuum" + K10_PRO_VACUUM = "k10_pro_vacuum" + K10_PRO_COMBO_VACUUM = "k10_pro_combo_vacumm" + HUB3 = "hub3" + LOCK_LITE = "lock_lite" + LOCK_ULTRA = "lock_ultra" + AIR_PURIFIER = "air_purifier" + AIR_PURIFIER_TABLE = "air_purifier_table" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -56,6 +66,15 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1, SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE, SwitchbotModel.CIRCULATOR_FAN: SupportedModels.CIRCULATOR_FAN, + SwitchbotModel.K20_VACUUM: SupportedModels.K20_VACUUM, + SwitchbotModel.S10_VACUUM: SupportedModels.S10_VACUUM, + SwitchbotModel.K10_VACUUM: SupportedModels.K10_VACUUM, + SwitchbotModel.K10_PRO_VACUUM: SupportedModels.K10_PRO_VACUUM, + SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM, + SwitchbotModel.LOCK_LITE: SupportedModels.LOCK_LITE, + SwitchbotModel.LOCK_ULTRA: SupportedModels.LOCK_ULTRA, + SwitchbotModel.AIR_PURIFIER: SupportedModels.AIR_PURIFIER, + SwitchbotModel.AIR_PURIFIER_TABLE: SupportedModels.AIR_PURIFIER_TABLE, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -68,6 +87,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.LEAK: SupportedModels.LEAK, SwitchbotModel.REMOTE: SupportedModels.REMOTE, SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER, + SwitchbotModel.HUB3: SupportedModels.HUB3, } SUPPORTED_MODEL_TYPES = ( @@ -79,6 +99,10 @@ ENCRYPTED_MODELS = { SwitchbotModel.RELAY_SWITCH_1PM, SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO, + SwitchbotModel.LOCK_LITE, + SwitchbotModel.LOCK_ULTRA, + SwitchbotModel.AIR_PURIFIER, + SwitchbotModel.AIR_PURIFIER_TABLE, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -88,6 +112,10 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ SwitchbotModel.LOCK_PRO: switchbot.SwitchbotLock, SwitchbotModel.RELAY_SWITCH_1PM: switchbot.SwitchbotRelaySwitch, SwitchbotModel.RELAY_SWITCH_1: switchbot.SwitchbotRelaySwitch, + SwitchbotModel.LOCK_LITE: switchbot.SwitchbotLock, + SwitchbotModel.LOCK_ULTRA: switchbot.SwitchbotLock, + SwitchbotModel.AIR_PURIFIER: switchbot.SwitchbotAirPurifier, + SwitchbotModel.AIR_PURIFIER_TABLE: switchbot.SwitchbotAirPurifier, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index bb73339aa05..9124dc7f846 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator -from .entity import SwitchbotEntity +from .entity import SwitchbotEntity, exception_handler # Initialize the logger _LOGGER = logging.getLogger(__name__) @@ -76,6 +76,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): if self._attr_current_cover_position is not None: self._attr_is_closed = self._attr_current_cover_position <= 20 + @exception_handler async def async_open_cover(self, **kwargs: Any) -> None: """Open the curtain.""" @@ -85,6 +86,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() + @exception_handler async def async_close_cover(self, **kwargs: Any) -> None: """Close the curtain.""" @@ -94,6 +96,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() + @exception_handler async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the moving of this device.""" @@ -103,6 +106,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() + @exception_handler async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" position = kwargs.get(ATTR_POSITION) @@ -161,6 +165,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): _tilt > self.CLOSED_UP_THRESHOLD ) + @exception_handler async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the tilt.""" @@ -168,6 +173,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._last_run_success = bool(await self._device.open()) self.async_write_ha_state() + @exception_handler async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the tilt.""" @@ -175,6 +181,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._last_run_success = bool(await self._device.close()) self.async_write_ha_state() + @exception_handler async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the moving of this device.""" @@ -182,6 +189,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._last_run_success = bool(await self._device.stop()) self.async_write_ha_state() + @exception_handler async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" position = kwargs.get(ATTR_TILT_POSITION) @@ -237,6 +245,7 @@ class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): if self._attr_current_cover_position is not None: self._attr_is_closed = self._attr_current_cover_position <= 20 + @exception_handler async def async_open_cover(self, **kwargs: Any) -> None: """Open the roller shade.""" @@ -246,6 +255,7 @@ class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() + @exception_handler async def async_close_cover(self, **kwargs: Any) -> None: """Close the roller shade.""" @@ -255,6 +265,7 @@ class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() + @exception_handler async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the moving of roller shade.""" @@ -264,6 +275,7 @@ class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() + @exception_handler async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index 282d23bfd1a..b7ee36fc1ae 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -2,22 +2,24 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Coroutine, Mapping import logging -from typing import Any +from typing import Any, Concatenate from switchbot import Switchbot, SwitchbotDevice +from switchbot.devices.device import SwitchbotOperationError from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, ) from homeassistant.const import ATTR_CONNECTIONS 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 from homeassistant.helpers.entity import ToggleEntity -from .const import MANUFACTURER +from .const import DOMAIN, MANUFACTURER from .coordinator import SwitchbotDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -88,11 +90,33 @@ class SwitchbotEntity( await self._device.update() +def exception_handler[_EntityT: SwitchbotEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Switchbot calls to handle exceptions.. + + A decorator that wraps the passed in function, catches Switchbot errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except SwitchbotOperationError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="operation_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler + + class SwitchbotSwitchedEntity(SwitchbotEntity, ToggleEntity): """Base class for Switchbot entities that can be turned on and off.""" _device: Switchbot + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" _LOGGER.debug("Turn Switchbot device on %s", self._address) @@ -102,6 +126,7 @@ class SwitchbotSwitchedEntity(SwitchbotEntity, ToggleEntity): self._attr_is_on = True self.async_write_ha_state() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" _LOGGER.debug("Turn Switchbot device off %s", self._address) diff --git a/homeassistant/components/switchbot/fan.py b/homeassistant/components/switchbot/fan.py index f704af309bf..9a7260f5925 100644 --- a/homeassistant/components/switchbot/fan.py +++ b/homeassistant/components/switchbot/fan.py @@ -6,7 +6,7 @@ import logging from typing import Any import switchbot -from switchbot import FanMode +from switchbot import AirPurifierMode, FanMode from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator -from .entity import SwitchbotEntity +from .entity import SwitchbotEntity, exception_handler _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -27,7 +27,10 @@ async def async_setup_entry( ) -> None: """Set up Switchbot fan based on a config entry.""" coordinator = entry.runtime_data - async_add_entities([SwitchBotFanEntity(coordinator)]) + if isinstance(coordinator.device, switchbot.SwitchbotAirPurifier): + async_add_entities([SwitchBotAirPurifierEntity(coordinator)]) + else: + async_add_entities([SwitchBotFanEntity(coordinator)]) class SwitchBotFanEntity(SwitchbotEntity, FanEntity, RestoreEntity): @@ -120,3 +123,65 @@ class SwitchBotFanEntity(SwitchbotEntity, FanEntity, RestoreEntity): _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() + + +class SwitchBotAirPurifierEntity(SwitchbotEntity, FanEntity): + """Representation of a Switchbot air purifier.""" + + _device: switchbot.SwitchbotAirPurifier + _attr_supported_features = ( + FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + _attr_preset_modes = AirPurifierMode.get_modes() + _attr_translation_key = "air_purifier" + _attr_name = None + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + return self._device.is_on() + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._device.get_current_mode() + + @exception_handler + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the air purifier.""" + + _LOGGER.debug( + "Switchbot air purifier 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() + + @exception_handler + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the air purifier.""" + + _LOGGER.debug( + "Switchbot air purifier 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() + + @exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the air purifier.""" + + _LOGGER.debug("Switchbot air purifier 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/humidifier.py b/homeassistant/components/switchbot/humidifier.py index 34a24948df1..c15cf7ac9c6 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry -from .entity import SwitchbotSwitchedEntity +from .entity import SwitchbotSwitchedEntity, exception_handler PARALLEL_UPDATES = 0 @@ -55,11 +55,13 @@ class SwitchBotHumidifier(SwitchbotSwitchedEntity, HumidifierEntity): """Return the humidity we try to reach.""" return self._device.get_target_humidity() + @exception_handler async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" self._last_run_success = bool(await self._device.set_level(humidity)) self.async_write_ha_state() + @exception_handler async def async_set_mode(self, mode: str) -> None: """Set new target humidity.""" if mode == MODE_AUTO: diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index a1c1682d255..9dd46e0717a 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -12,6 +12,24 @@ } } } + }, + "air_purifier": { + "default": "mdi:air-purifier", + "state": { + "off": "mdi:air-purifier-off" + }, + "state_attributes": { + "preset_mode": { + "state": { + "level_1": "mdi:fan-speed-1", + "level_2": "mdi:fan-speed-2", + "level_3": "mdi:fan-speed-3", + "auto": "mdi:auto-mode", + "pet": "mdi:paw", + "sleep": "mdi:power-sleep" + } + } + } } } } diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index 4b9a7e1b988..ad37f3ebec0 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -4,7 +4,8 @@ from __future__ import annotations from typing import Any, cast -from switchbot import ColorMode as SwitchBotColorMode, SwitchbotBaseLight +import switchbot +from switchbot import ColorMode as SwitchBotColorMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -17,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator -from .entity import SwitchbotEntity +from .entity import SwitchbotEntity, exception_handler SWITCHBOT_COLOR_MODE_TO_HASS = { SwitchBotColorMode.RGB: ColorMode.RGB, @@ -39,7 +40,7 @@ async def async_setup_entry( class SwitchbotLightEntity(SwitchbotEntity, LightEntity): """Representation of switchbot light bulb.""" - _device: SwitchbotBaseLight + _device: switchbot.SwitchbotBaseLight _attr_name = None def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: @@ -66,6 +67,7 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): self._attr_rgb_color = device.rgb self._attr_color_mode = ColorMode.RGB + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" brightness = round( @@ -89,6 +91,7 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): return await self._device.turn_on() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" await self._device.turn_off() diff --git a/homeassistant/components/switchbot/lock.py b/homeassistant/components/switchbot/lock.py index d9ff2433cf8..069b01521c4 100644 --- a/homeassistant/components/switchbot/lock.py +++ b/homeassistant/components/switchbot/lock.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator -from .entity import SwitchbotEntity +from .entity import SwitchbotEntity, exception_handler PARALLEL_UPDATES = 0 @@ -54,11 +54,13 @@ class SwitchBotLock(SwitchbotEntity, LockEntity): LockStatus.UNLOCKING_STOP, } + @exception_handler async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" self._last_run_success = await self._device.lock() self.async_write_ha_state() + @exception_handler async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" if self._attr_supported_features & (LockEntityFeature.OPEN): @@ -67,6 +69,7 @@ class SwitchBotLock(SwitchbotEntity, LockEntity): self._last_run_success = await self._device.unlock() self.async_write_ha_state() + @exception_handler async def async_open(self, **kwargs: Any) -> None: """Open the lock.""" self._last_run_success = await self._device.unlock() diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 176f85ab389..eadd3ad2a2d 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -32,12 +32,14 @@ "@RenierM26", "@murtas", "@Eloston", - "@dsypniewski" + "@dsypniewski", + "@zerzhang" ], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.60.1"] + "quality_scale": "gold", + "requirements": ["PySwitchbot==0.64.1"] } diff --git a/homeassistant/components/switchbot/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml index e9d8a9626ac..5226016c527 100644 --- a/homeassistant/components/switchbot/quality_scale.yaml +++ b/homeassistant/components/switchbot/quality_scale.yaml @@ -40,13 +40,11 @@ rules: 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: | - Consider using snapshots for fixating all the entities a device creates. + status: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: | @@ -70,7 +68,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: | diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index d68c913db15..75ac0f7bc74 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from switchbot.const.air_purifier import AirQualityLevel + from homeassistant.components.bluetooth import async_last_service_info from homeassistant.components.sensor import ( SensorDeviceClass, @@ -102,6 +104,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, ), + "aqi_level": SensorEntityDescription( + key="aqi_level", + translation_key="aqi_quality_level", + device_class=SensorDeviceClass.ENUM, + options=[member.name.lower() for member in AirQualityLevel], + ), } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index f0d075eafc9..c758ae645ae 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -105,6 +105,15 @@ }, "light_level": { "name": "Light level" + }, + "aqi_quality_level": { + "name": "Air quality level", + "state": { + "excellent": "Excellent", + "good": "Good", + "moderate": "Moderate", + "unhealthy": "Unhealthy" + } } }, "cover": { @@ -179,7 +188,53 @@ } } } + }, + "air_purifier": { + "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": { + "level_1": "Level 1", + "level_2": "Level 2", + "level_3": "Level 3", + "auto": "[%key:common::state::auto%]", + "pet": "Pet", + "sleep": "Sleep" + } + } + } } + }, + "vacuum": { + "vacuum": { + "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%]" + } + } + } + } + } + }, + "exceptions": { + "operation_error": { + "message": "An error occurred while performing the action: {error}" + }, + "value_error": { + "message": "Switchbot device initialization failed because of incorrect configuration parameters: {error}" + }, + "advertising_state_error": { + "message": "{address} is not advertising state" + }, + "device_not_found_error": { + "message": "Could not find Switchbot {sensor_type} with address {address}" } } } diff --git a/homeassistant/components/switchbot/vacuum.py b/homeassistant/components/switchbot/vacuum.py new file mode 100644 index 00000000000..9dade6b7f46 --- /dev/null +++ b/homeassistant/components/switchbot/vacuum.py @@ -0,0 +1,126 @@ +"""Support for switchbot vacuums.""" + +from __future__ import annotations + +from typing import Any + +import switchbot +from switchbot import SwitchbotModel + +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +PARALLEL_UPDATES = 0 + +DEVICE_SUPPORT_PROTOCOL_VERSION_1 = [ + SwitchbotModel.K10_VACUUM, + SwitchbotModel.K10_PRO_VACUUM, +] + +PROTOCOL_VERSION_1_STATE_TO_HA_STATE: dict[int, VacuumActivity] = { + 0: VacuumActivity.CLEANING, + 1: VacuumActivity.DOCKED, +} + +PROTOCOL_VERSION_2_STATE_TO_HA_STATE: dict[int, VacuumActivity] = { + 1: VacuumActivity.IDLE, # idle + 2: VacuumActivity.DOCKED, # charge + 3: VacuumActivity.DOCKED, # charge complete + 4: VacuumActivity.IDLE, # self-check + 5: VacuumActivity.IDLE, # the drum is moist + 6: VacuumActivity.CLEANING, # exploration + 7: VacuumActivity.CLEANING, # re-location + 8: VacuumActivity.CLEANING, # cleaning and sweeping + 9: VacuumActivity.CLEANING, # cleaning + 10: VacuumActivity.CLEANING, # sweeping + 11: VacuumActivity.PAUSED, # pause + 12: VacuumActivity.CLEANING, # getting out of trouble + 13: VacuumActivity.ERROR, # trouble + 14: VacuumActivity.CLEANING, # mpo cleaning + 15: VacuumActivity.RETURNING, # returning + 16: VacuumActivity.CLEANING, # deep cleaning + 17: VacuumActivity.CLEANING, # Sewage extraction + 18: VacuumActivity.CLEANING, # replenish water for mop + 19: VacuumActivity.CLEANING, # dust collection + 20: VacuumActivity.CLEANING, # dry + 21: VacuumActivity.IDLE, # dormant + 22: VacuumActivity.IDLE, # network configuration + 23: VacuumActivity.CLEANING, # remote control + 24: VacuumActivity.RETURNING, # return to base + 25: VacuumActivity.IDLE, # shut down + 26: VacuumActivity.IDLE, # mark water base station + 27: VacuumActivity.IDLE, # rinse the filter screen + 28: VacuumActivity.IDLE, # mark humidifier location + 29: VacuumActivity.IDLE, # on the way to the humidifier + 30: VacuumActivity.IDLE, # add water for humidifier + 31: VacuumActivity.IDLE, # upgrading + 32: VacuumActivity.PAUSED, # pause during recharging + 33: VacuumActivity.IDLE, # integrated with the platform + 34: VacuumActivity.CLEANING, # working for the platform +} + +SWITCHBOT_VACUUM_STATE_MAP: dict[int, dict[int, VacuumActivity]] = { + 1: PROTOCOL_VERSION_1_STATE_TO_HA_STATE, + 2: PROTOCOL_VERSION_2_STATE_TO_HA_STATE, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switchbot vacuum.""" + async_add_entities([SwitchbotVacuumEntity(entry.runtime_data)]) + + +class SwitchbotVacuumEntity(SwitchbotEntity, StateVacuumEntity): + """Representation of a SwitchBot vacuum.""" + + _device: switchbot.SwitchbotVacuum + _attr_supported_features = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.START + | VacuumEntityFeature.STATE + ) + _attr_translation_key = "vacuum" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the Switchbot.""" + super().__init__(coordinator) + self.protocol_version = ( + 1 if coordinator.model in DEVICE_SUPPORT_PROTOCOL_VERSION_1 else 2 + ) + + @property + def activity(self) -> VacuumActivity | None: + """Return the status of the vacuum cleaner.""" + status_code = self._device.get_work_status() + return SWITCHBOT_VACUUM_STATE_MAP[self.protocol_version].get(status_code) + + @property + def battery_level(self) -> int: + """Return the vacuum battery.""" + return self._device.get_battery() + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + self._last_run_success = bool( + await self._device.clean_up(self.protocol_version) + ) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return to dock.""" + self._last_run_success = bool( + await self._device.return_to_dock(self.protocol_version) + ) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 6f36739e2fc..7b7f60589f0 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -1,21 +1,32 @@ """SwitchBot via API integration.""" from asyncio import gather +from collections.abc import Awaitable, Callable +import contextlib from dataclasses import dataclass, field from logging import getLogger -from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotAPI +from aiohttp import web +from switchbot_api import ( + Device, + Remote, + SwitchBotAPI, + SwitchBotAuthenticationError, + SwitchBotConnectionError, +) +from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN +from .const import DOMAIN, ENTRY_TITLE from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.LOCK, @@ -29,12 +40,17 @@ PLATFORMS: list[Platform] = [ class SwitchbotDevices: """Switchbot devices data.""" - buttons: list[Device] = field(default_factory=list) - climates: list[Remote] = field(default_factory=list) - switches: list[Device | Remote] = field(default_factory=list) - sensors: list[Device] = field(default_factory=list) - vacuums: list[Device] = field(default_factory=list) - locks: list[Device] = field(default_factory=list) + binary_sensors: list[tuple[Device, SwitchBotCoordinator]] = field( + default_factory=list + ) + buttons: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + climates: list[tuple[Remote, SwitchBotCoordinator]] = field(default_factory=list) + switches: list[tuple[Device | Remote, SwitchBotCoordinator]] = field( + default_factory=list + ) + sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -51,10 +67,12 @@ async def coordinator_for_device( api: SwitchBotAPI, device: Device | Remote, coordinators_by_id: dict[str, SwitchBotCoordinator], + manageable_by_webhook: bool = False, ) -> SwitchBotCoordinator: """Instantiate coordinator and adds to list for gathering.""" coordinator = coordinators_by_id.setdefault( - device.device_id, SwitchBotCoordinator(hass, entry, api, device) + device.device_id, + SwitchBotCoordinator(hass, entry, api, device, manageable_by_webhook), ) if coordinator.data is None: @@ -131,7 +149,7 @@ async def make_device_data( "Robot Vacuum Cleaner S1 Plus", ]: coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id, True ) devices_data.vacuums.append((device, coordinator)) @@ -141,6 +159,7 @@ async def make_device_data( ) devices_data.locks.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) + devices_data.binary_sensors.append((device, coordinator)) if isinstance(device, Device) and device.device_type in ["Bot"]: coordinator = await coordinator_for_device( @@ -162,12 +181,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = SwitchBotAPI(token=token, secret=secret) try: devices = await api.list_devices() - except InvalidAuth as ex: + except SwitchBotAuthenticationError as ex: _LOGGER.error( "Invalid authentication while connecting to SwitchBot API: %s", ex ) return False - except CannotConnect as ex: + except SwitchBotConnectionError as ex: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) coordinators_by_id: dict[str, SwitchBotCoordinator] = {} @@ -179,7 +198,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = SwitchbotCloudData( api=api, devices=switchbot_devices ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + await _initialize_webhook(hass, entry, api, coordinators_by_id) + return True @@ -189,3 +212,120 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def _initialize_webhook( + hass: HomeAssistant, + entry: ConfigEntry, + api: SwitchBotAPI, + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> None: + """Initialize webhook if needed.""" + if any( + coordinator.manageable_by_webhook() + for coordinator in coordinators_by_id.values() + ): + if CONF_WEBHOOK_ID not in entry.data: + new_data = entry.data.copy() + if CONF_WEBHOOK_ID not in new_data: + # create new id and new conf + new_data[CONF_WEBHOOK_ID] = webhook.async_generate_id() + + hass.config_entries.async_update_entry(entry, data=new_data) + + # register webhook + webhook_name = ENTRY_TITLE + if entry.title != ENTRY_TITLE: + webhook_name = f"{ENTRY_TITLE} {entry.title}" + + with contextlib.suppress(Exception): + webhook.async_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + _create_handle_webhook(coordinators_by_id), + ) + + webhook_url = webhook.async_generate_url( + hass, + entry.data[CONF_WEBHOOK_ID], + ) + + # check if webhook is configured in switchbot cloud + check_webhook_result = None + with contextlib.suppress(Exception): + check_webhook_result = await api.get_webook_configuration() + + actual_webhook_urls = ( + check_webhook_result["urls"] + if check_webhook_result and "urls" in check_webhook_result + else [] + ) + need_add_webhook = ( + len(actual_webhook_urls) == 0 or webhook_url not in actual_webhook_urls + ) + need_clean_previous_webhook = ( + len(actual_webhook_urls) > 0 and webhook_url not in actual_webhook_urls + ) + + if need_clean_previous_webhook: + # it seems is impossible to register multiple webhook. + # So, if webhook already exists, we delete it + await api.delete_webhook(actual_webhook_urls[0]) + _LOGGER.debug( + "Deleted previous Switchbot cloud webhook url: %s", + actual_webhook_urls[0], + ) + + if need_add_webhook: + # call api for register webhookurl + await api.setup_webhook(webhook_url) + _LOGGER.debug("Registered Switchbot cloud webhook at hass: %s", webhook_url) + + for coordinator in coordinators_by_id.values(): + coordinator.webhook_subscription_listener(True) + + _LOGGER.debug("Registered Switchbot cloud webhook at: %s", webhook_url) + + +def _create_handle_webhook( + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> Callable[[HomeAssistant, str, web.Request], Awaitable[None]]: + """Create a webhook handler.""" + + async def _internal_handle_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request + ) -> None: + """Handle webhook callback.""" + if not request.body_exists: + _LOGGER.debug("Received invalid request from switchbot webhook") + return + + data = await request.json() + # Structure validation + if ( + not isinstance(data, dict) + or "eventType" not in data + or data["eventType"] != "changeReport" + or "eventVersion" not in data + or data["eventVersion"] != "1" + or "context" not in data + or not isinstance(data["context"], dict) + or "deviceType" not in data["context"] + or "deviceMac" not in data["context"] + ): + _LOGGER.debug("Received invalid data from switchbot webhook %s", repr(data)) + return + + deviceMac = data["context"]["deviceMac"] + + if deviceMac not in coordinators_by_id: + _LOGGER.error( + "Received data for unknown entity from switchbot webhook: %s", data + ) + return + + coordinators_by_id[deviceMac].async_set_updated_data(data["context"]) + + return _internal_handle_webhook diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py new file mode 100644 index 00000000000..14278072c83 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -0,0 +1,101 @@ +"""Support for SwitchBot Cloud binary sensors.""" + +from dataclasses import dataclass + +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .coordinator import SwitchBotCoordinator +from .entity import SwitchBotCloudEntity + + +@dataclass(frozen=True) +class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Switchbot Cloud binary sensor.""" + + # Value or values to consider binary sensor to be "on" + on_value: bool | str = True + + +CALIBRATION_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription( + key="calibrate", + name="Calibration", + translation_key="calibration", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + on_value=False, +) + +DOOR_OPEN_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription( + key="doorState", + device_class=BinarySensorDeviceClass.DOOR, + on_value="opened", +) + +BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { + "Smart Lock": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), + "Smart Lock Pro": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + + async_add_entities( + SwitchBotCloudBinarySensor(data.api, device, coordinator, description) + for device, coordinator in data.devices.binary_sensors + for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[ + device.device_type + ] + ) + + +class SwitchBotCloudBinarySensor(SwitchBotCloudEntity, BinarySensorEntity): + """Representation of a Switchbot binary sensor.""" + + entity_description: SwitchBotCloudBinarySensorEntityDescription + + def __init__( + self, + api: SwitchBotAPI, + device: Device, + coordinator: SwitchBotCoordinator, + description: SwitchBotCloudBinarySensorEntityDescription, + ) -> None: + """Initialize SwitchBot Cloud sensor entity.""" + super().__init__(api, device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{device.device_id}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Set attributes from coordinator data.""" + if not self.coordinator.data: + return None + + return ( + self.coordinator.data.get(self.entity_description.key) + == self.entity_description.on_value + ) diff --git a/homeassistant/components/switchbot_cloud/config_flow.py b/homeassistant/components/switchbot_cloud/config_flow.py index eafe823bc0b..0ba1e0295e0 100644 --- a/homeassistant/components/switchbot_cloud/config_flow.py +++ b/homeassistant/components/switchbot_cloud/config_flow.py @@ -3,7 +3,11 @@ from logging import getLogger from typing import Any -from switchbot_api import CannotConnect, InvalidAuth, SwitchBotAPI +from switchbot_api import ( + SwitchBotAPI, + SwitchBotAuthenticationError, + SwitchBotConnectionError, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -36,9 +40,9 @@ class SwitchBotCloudConfigFlow(ConfigFlow, domain=DOMAIN): await SwitchBotAPI( token=user_input[CONF_API_TOKEN], secret=user_input[CONF_API_KEY] ).list_devices() - except CannotConnect: + except SwitchBotConnectionError: errors["base"] = "cannot_connect" - except InvalidAuth: + except SwitchBotAuthenticationError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/switchbot_cloud/coordinator.py b/homeassistant/components/switchbot_cloud/coordinator.py index 02ead5940e4..9fc8f64aa68 100644 --- a/homeassistant/components/switchbot_cloud/coordinator.py +++ b/homeassistant/components/switchbot_cloud/coordinator.py @@ -4,7 +4,7 @@ from asyncio import timeout from logging import getLogger from typing import Any -from switchbot_api import CannotConnect, Device, Remote, SwitchBotAPI +from switchbot_api import Device, Remote, SwitchBotAPI, SwitchBotConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -23,6 +23,8 @@ class SwitchBotCoordinator(DataUpdateCoordinator[Status]): config_entry: ConfigEntry _api: SwitchBotAPI _device_id: str + _manageable_by_webhook: bool + _webhooks_connected: bool = False def __init__( self, @@ -30,6 +32,7 @@ class SwitchBotCoordinator(DataUpdateCoordinator[Status]): config_entry: ConfigEntry, api: SwitchBotAPI, device: Device | Remote, + manageable_by_webhook: bool, ) -> None: """Initialize SwitchBot Cloud.""" super().__init__( @@ -42,6 +45,20 @@ class SwitchBotCoordinator(DataUpdateCoordinator[Status]): self._api = api self._device_id = device.device_id self._should_poll = not isinstance(device, Remote) + self._manageable_by_webhook = manageable_by_webhook + + def webhook_subscription_listener(self, connected: bool) -> None: + """Call when webhook status changed.""" + if self._manageable_by_webhook: + self._webhooks_connected = connected + if connected: + self.update_interval = None + else: + self.update_interval = DEFAULT_SCAN_INTERVAL + + def manageable_by_webhook(self) -> bool: + """Return update_by_webhook value.""" + return self._manageable_by_webhook async def _async_update_data(self) -> Status: """Fetch data from API endpoint.""" @@ -53,5 +70,5 @@ class SwitchBotCoordinator(DataUpdateCoordinator[Status]): status: Status = await self._api.get_status(self._device_id) _LOGGER.debug("Refreshing %s with %s", self._device_id, status) return status - except CannotConnect as err: + except SwitchBotConnectionError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 99f909e91ab..e0c49d9e739 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -3,9 +3,10 @@ "name": "SwitchBot Cloud", "codeowners": ["@SeraphicRav", "@laurence-presland", "@Gigatrappeur"], "config_flow": true, + "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["switchbot_api"], - "requirements": ["switchbot-api==2.3.1"] + "requirements": ["switchbot-api==2.4.0"] } diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index 697ea8aea6e..d6ad17969db 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -111,7 +111,7 @@ class FolderSensor(SensorEntity): return self._state["state"] @property - def available(self): + def available(self) -> bool: """Could the device be accessed during the last update call.""" return self._state is not None diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index cd054c7eb74..3022b4c2af9 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.7.2"], + "requirements": ["py-synologydsm-api==2.7.3"], "ssdp": [ { "manufacturer": "Synology", diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 6234f5e8dd0..7fafe1fecb3 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -145,6 +145,17 @@ class SynologyPhotosMediaSource(MediaSource): can_expand=True, ) ] + ret += [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{item.identifier}/shared", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="Shared space", + can_play=False, + can_expand=True, + ) + ] ret.extend( BrowseMediaSource( domain=DOMAIN, @@ -162,13 +173,24 @@ class SynologyPhotosMediaSource(MediaSource): # Request items of album # Get Items - album = SynoPhotosAlbum(int(identifier.album_id), "", 0, identifier.passphrase) - try: - album_items = await diskstation.api.photos.get_items_from_album( - album, 0, 1000 + if identifier.album_id == "shared": + # Get items from shared space + try: + album_items = await diskstation.api.photos.get_items_from_shared_space( + 0, 1000 + ) + except SynologyDSMException: + return [] + else: + album = SynoPhotosAlbum( + int(identifier.album_id), "", 0, identifier.passphrase ) - except SynologyDSMException: - return [] + try: + album_items = await diskstation.api.photos.get_items_from_album( + album, 0, 1000 + ) + except SynologyDSMException: + return [] assert album_items is not None ret = [] diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index d1994075f12..74768ee01fa 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -8,7 +8,13 @@ import PyTado.exceptions from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + APPLICATION_NAME, + CONF_PASSWORD, + CONF_USERNAME, + Platform, + __version__ as HA_VERSION, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -74,7 +80,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool def create_tado_instance() -> tuple[Tado, str]: """Create a Tado instance, this time with a previously obtained refresh token.""" - tado = Tado(saved_refresh_token=entry.data[CONF_REFRESH_TOKEN]) + tado = Tado( + saved_refresh_token=entry.data[CONF_REFRESH_TOKEN], + user_agent=f"{APPLICATION_NAME}/{HA_VERSION}", + ) return tado, tado.device_activation_status() try: diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 5f3aa1de1e4..09c6ec40208 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) SCAN_INTERVAL = timedelta(minutes=5) -SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) +SCAN_MOBILE_DEVICE_INTERVAL = timedelta(minutes=5) class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index b252a396689..8350f300c03 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.14"] + "requirements": ["python-tado==0.18.15"] } diff --git a/homeassistant/components/tami4/strings.json b/homeassistant/components/tami4/strings.json index 040c18fc56d..b89ccbe8bd9 100644 --- a/homeassistant/components/tami4/strings.json +++ b/homeassistant/components/tami4/strings.json @@ -29,17 +29,17 @@ "config": { "step": { "user": { - "title": "SMS Verification", + "title": "SMS verification", "description": "Enter your phone number (same as what you used to register to the tami4 app)", "data": { - "phone": "Phone Number" + "phone": "Phone number" } }, "otp": { "title": "[%key:component::tami4::config::step::user::title%]", "description": "Enter the code you received via SMS", "data": { - "otp": "SMS Code" + "otp": "SMS code" } } }, diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 05260845a03..29aba780f26 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -76,10 +76,10 @@ }, "switch": { "auto_charge": { - "name": "Auto charge" + "name": "Auto-charge" }, "session_active": { - "name": "Charging Enabled" + "name": "Charging enabled" } } }, diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index bca51f08f93..012e82318ed 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["aiotedee"], "quality_scale": "platinum", - "requirements": ["aiotedee==0.2.20"] + "requirements": ["aiotedee==0.2.23"] } diff --git a/homeassistant/components/telegram/notify.py b/homeassistant/components/telegram/notify.py index adb947bcf6b..6b9cf43bf71 100644 --- a/homeassistant/components/telegram/notify.py +++ b/homeassistant/components/telegram/notify.py @@ -20,17 +20,17 @@ from homeassistant.components.telegram_bot import ( ATTR_MESSAGE_TAG, ATTR_MESSAGE_THREAD_ID, ATTR_PARSER, + DOMAIN as TELEGRAM_BOT_DOMAIN, ) from homeassistant.const import ATTR_LOCATION from homeassistant.core import HomeAssistant from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as TELEGRAM_DOMAIN, PLATFORMS +from . import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -DOMAIN = "telegram_bot" ATTR_KEYBOARD = "keyboard" ATTR_INLINE_KEYBOARD = "inline_keyboard" ATTR_PHOTO = "photo" @@ -52,7 +52,7 @@ def get_service( ) -> TelegramNotificationService: """Get the Telegram notification service.""" - setup_reload_service(hass, TELEGRAM_DOMAIN, PLATFORMS) + setup_reload_service(hass, DOMAIN, PLATFORMS) chat_id = config.get(CONF_CHAT_ID) return TelegramNotificationService(hass, chat_id) @@ -115,37 +115,45 @@ class TelegramNotificationService(BaseNotificationService): photos = photos if isinstance(photos, list) else [photos] for photo_data in photos: service_data.update(photo_data) - self.hass.services.call(DOMAIN, "send_photo", service_data=service_data) + self.hass.services.call( + TELEGRAM_BOT_DOMAIN, "send_photo", service_data=service_data + ) return None if data is not None and ATTR_VIDEO in data: videos = data.get(ATTR_VIDEO) videos = videos if isinstance(videos, list) else [videos] for video_data in videos: service_data.update(video_data) - self.hass.services.call(DOMAIN, "send_video", service_data=service_data) + self.hass.services.call( + TELEGRAM_BOT_DOMAIN, "send_video", service_data=service_data + ) return None if data is not None and ATTR_VOICE in data: voices = data.get(ATTR_VOICE) voices = voices if isinstance(voices, list) else [voices] for voice_data in voices: service_data.update(voice_data) - self.hass.services.call(DOMAIN, "send_voice", service_data=service_data) + self.hass.services.call( + TELEGRAM_BOT_DOMAIN, "send_voice", service_data=service_data + ) return None if data is not None and ATTR_LOCATION in data: service_data.update(data.get(ATTR_LOCATION)) return self.hass.services.call( - DOMAIN, "send_location", service_data=service_data + TELEGRAM_BOT_DOMAIN, "send_location", service_data=service_data ) if data is not None and ATTR_DOCUMENT in data: service_data.update(data.get(ATTR_DOCUMENT)) return self.hass.services.call( - DOMAIN, "send_document", service_data=service_data + TELEGRAM_BOT_DOMAIN, "send_document", service_data=service_data ) # Send message _LOGGER.debug( - "TELEGRAM NOTIFIER calling %s.send_message with %s", DOMAIN, service_data + "TELEGRAM NOTIFIER calling %s.send_message with %s", + TELEGRAM_BOT_DOMAIN, + service_data, ) return self.hass.services.call( - DOMAIN, "send_message", service_data=service_data + TELEGRAM_BOT_DOMAIN, "send_message", service_data=service_data ) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 208077a4153..725a73338fa 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Generator, Sequence from enum import Enum import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -21,6 +22,7 @@ from homeassistant.const import ( ATTR_CODE, CONF_DEVICE_ID, CONF_NAME, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, @@ -28,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( @@ -36,11 +38,18 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, ) from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify -from .const import DOMAIN -from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .entity import AbstractTemplateEntity +from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, + TEMPLATE_ENTITY_ICON_SCHEMA, + TemplateEntity, + rewrite_common_legacy_to_modern_conf, +) _LOGGER = logging.getLogger(__name__) _VALID_STATES = [ @@ -51,21 +60,22 @@ _VALID_STATES = [ AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMING, AlarmControlPanelState.DISARMED, + AlarmControlPanelState.DISARMING, AlarmControlPanelState.PENDING, AlarmControlPanelState.TRIGGERED, STATE_UNAVAILABLE, ] +CONF_ALARM_CONTROL_PANELS = "panels" CONF_ARM_AWAY_ACTION = "arm_away" CONF_ARM_CUSTOM_BYPASS_ACTION = "arm_custom_bypass" CONF_ARM_HOME_ACTION = "arm_home" CONF_ARM_NIGHT_ACTION = "arm_night" CONF_ARM_VACATION_ACTION = "arm_vacation" -CONF_DISARM_ACTION = "disarm" -CONF_TRIGGER_ACTION = "trigger" -CONF_ALARM_CONTROL_PANELS = "panels" CONF_CODE_ARM_REQUIRED = "code_arm_required" CONF_CODE_FORMAT = "code_format" +CONF_DISARM_ACTION = "disarm" +CONF_TRIGGER_ACTION = "trigger" class TemplateCodeFormat(Enum): @@ -76,73 +86,140 @@ class TemplateCodeFormat(Enum): text = CodeFormat.TEXT -ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_VALUE_TEMPLATE: CONF_STATE, +} + +DEFAULT_NAME = "Template Alarm Control Panel" + +ALARM_CONTROL_PANEL_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional( + CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name + ): cv.enum(TemplateCodeFormat), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), +) + + +LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( { - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( TemplateCodeFormat ), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } ) PLATFORM_SCHEMA = ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ALARM_CONTROL_PANELS): cv.schema_with_slug_keys( - ALARM_CONTROL_PANEL_SCHEMA + LEGACY_ALARM_CONTROL_PANEL_SCHEMA ), } ) ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( TemplateCodeFormat ), vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, } ) -async def _async_create_entities( - hass: HomeAssistant, config: dict[str, Any] -) -> list[AlarmControlPanelTemplate]: - """Create Template Alarm Control Panels.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy alarm control panel configuration definitions to modern ones.""" alarm_control_panels = [] - for object_id, entity_config in config[CONF_ALARM_CONTROL_PANELS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_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) + + alarm_control_panels.append(entity_conf) + + return alarm_control_panels + + +@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 alarm control panels.""" + alarm_control_panels = [] + + 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}" alarm_control_panels.append( AlarmControlPanelTemplate( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return alarm_control_panels + async_add_entities(alarm_control_panels) + + +def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: + """Rewrite option configuration to modern configuration.""" + option_config = {**option_config} + + if CONF_VALUE_TEMPLATE in option_config: + option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) + + return option_config async def async_setup_entry( @@ -153,12 +230,12 @@ async def async_setup_entry( """Initialize config entry.""" _options = dict(config_entry.options) _options.pop("template_type") + _options = rewrite_options_to_modern_conf(_options) validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) async_add_entities( [ AlarmControlPanelTemplate( hass, - slugify(_options[CONF_NAME]), validated_config, config_entry.entry_id, ) @@ -172,36 +249,45 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Template Alarm Control Panels.""" - async_add_entities(await _async_create_entities(hass, config)) - - -class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, RestoreEntity): - """Representation of a templated Alarm Control Panel.""" - - _attr_should_poll = False - - def __init__( - self, - hass: HomeAssistant, - object_id: str, - config: dict, - unique_id: str | None, - ) -> None: - """Initialize the panel.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id + """Set up the Template cover.""" + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_ALARM_CONTROL_PANELS]), + None, ) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) - name = self._attr_name - assert name is not None - self._template = config.get(CONF_VALUE_TEMPLATE) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) + + +class AbstractTemplateAlarmControlPanel( + AbstractTemplateEntity, AlarmControlPanelEntity, RestoreEntity +): + """Representation of a templated Alarm Control Panel features.""" + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + self._template = config.get(CONF_STATE) + self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] self._attr_code_format = config[CONF_CODE_FORMAT].value - self._attr_supported_features = AlarmControlPanelEntityFeature(0) + self._state: AlarmControlPanelState | None = None + + def _register_scripts( + self, config: dict[str, Any] + ) -> Generator[ + tuple[str, Sequence[dict[str, Any]], AlarmControlPanelEntityFeature | int] + ]: for action_id, supported_feature in ( (CONF_DISARM_ACTION, 0), (CONF_ARM_AWAY_ACTION, AlarmControlPanelEntityFeature.ARM_AWAY), @@ -214,20 +300,15 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore ), (CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER), ): - # 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 + yield (action_id, action_config, supported_feature) - self._state: AlarmControlPanelState | None = None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) + @property + def alarm_state(self) -> AlarmControlPanelState | None: + """Return the state of the device.""" + return self._state - async def async_added_to_hass(self) -> None: - """Restore last state.""" - await super().async_added_to_hass() + async def _async_handle_restored_state(self) -> None: if ( (last_state := await self.async_get_last_state()) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) @@ -238,17 +319,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore ): self._state = AlarmControlPanelState(last_state.state) - @property - def alarm_state(self) -> AlarmControlPanelState | None: - """Return the state of the device.""" - return self._state - - @callback - def _update_state(self, result): - if isinstance(result, TemplateError): - self._state = None - return - + def _handle_state(self, result: Any) -> None: # Validate state if result in _VALID_STATES: self._state = result @@ -263,16 +334,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore ) self._state = None - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - if self._template: - self.add_template_attribute( - "_state", self._template, None, self._update_state - ) - super()._async_setup_templates() - - async def _async_alarm_arm(self, state, script, code): + async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any): """Arm the panel to specified state with supplied script.""" optimistic_set = False @@ -280,9 +342,10 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore self._state = state optimistic_set = True - await self.async_run_script( - script, run_variables={ATTR_CODE: code}, context=self._context - ) + if script: + await self.async_run_script( + script, run_variables={ATTR_CODE: code}, context=self._context + ) if optimistic_set: self.async_write_ha_state() @@ -342,3 +405,62 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore script=self._action_scripts.get(CONF_TRIGGER_ACTION), code=code, ) + + +class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPanel): + """Representation of a templated Alarm Control Panel.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict, + unique_id: str | None, + ) -> None: + """Initialize the panel.""" + TemplateEntity.__init__( + self, hass, config=config, fallback_name=None, unique_id=unique_id + ) + AbstractTemplateAlarmControlPanel.__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 + + self._attr_supported_features = AlarmControlPanelEntityFeature(0) + for action_id, action_config, supported_feature in self._register_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + 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() + await self._async_handle_restored_state() + + @callback + def _update_state(self, result): + if isinstance(result, TemplateError): + self._state = None + return + + self._handle_state(result) + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template: + self.add_template_attribute( + "_state", self._template, None, self._update_state + ) + super()._async_setup_templates() diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 7ef64e8077b..f0ec64eae2a 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -352,6 +352,8 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity self._to_render_simple.append(key) self._parse_result.add(key) + self._last_delay_from: bool | None = None + self._last_delay_to: bool | None = None self._delay_cancel: CALLBACK_TYPE | None = None self._auto_off_cancel: CALLBACK_TYPE | None = None self._auto_off_time: datetime | None = None @@ -388,6 +390,20 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity """Handle update of the data.""" self._process_data() + raw = self._rendered.get(CONF_STATE) + state = template.result_as_boolean(raw) + + key = CONF_DELAY_ON if state else CONF_DELAY_OFF + delay = self._rendered.get(key) or self._config.get(key) + + if ( + self._delay_cancel + and delay + and self._attr_is_on == self._last_delay_from + and state == self._last_delay_to + ): + return + if self._delay_cancel: self._delay_cancel() self._delay_cancel = None @@ -401,12 +417,6 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity self.async_write_ha_state() return - raw = self._rendered.get(CONF_STATE) - state = template.result_as_boolean(raw) - - key = CONF_DELAY_ON if state else CONF_DELAY_OFF - delay = self._rendered.get(key) or self._config.get(key) - # state without delay. None means rendering failed. if self._attr_is_on == state or state is None or delay is None: self._set_state(state) @@ -422,6 +432,8 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity return # state with delay. Cancelled if new trigger received + self._last_delay_from = self._attr_is_on + self._last_delay_to = state self._delay_cancel = async_call_later( self.hass, delay.total_seconds(), partial(self._set_state, state) ) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index ca643653cec..e87c9aee989 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -7,20 +7,26 @@ from typing import Any import voluptuous as vol -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.alarm_control_panel import ( + DOMAIN as DOMAIN_ALARM_CONTROL_PANEL, +) +from homeassistant.components.binary_sensor import DOMAIN as DOMAIN_BINARY_SENSOR from homeassistant.components.blueprint import ( 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 -from homeassistant.components.select import DOMAIN as SELECT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.components.button import DOMAIN as DOMAIN_BUTTON +from homeassistant.components.cover import DOMAIN as DOMAIN_COVER +from homeassistant.components.fan import DOMAIN as DOMAIN_FAN +from homeassistant.components.image import DOMAIN as DOMAIN_IMAGE +from homeassistant.components.light import DOMAIN as DOMAIN_LIGHT +from homeassistant.components.lock import DOMAIN as DOMAIN_LOCK +from homeassistant.components.number import DOMAIN as DOMAIN_NUMBER +from homeassistant.components.select import DOMAIN as DOMAIN_SELECT +from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR +from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH +from homeassistant.components.vacuum import DOMAIN as DOMAIN_VACUUM +from homeassistant.components.weather import DOMAIN as DOMAIN_WEATHER from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import ( CONF_ACTION, @@ -43,15 +49,19 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_notify_setup_error from . import ( + alarm_control_panel as alarm_control_panel_platform, binary_sensor as binary_sensor_platform, button as button_platform, cover as cover_platform, + fan as fan_platform, image as image_platform, light as light_platform, + lock as lock_platform, number as number_platform, select as select_platform, sensor as sensor_platform, switch as switch_platform, + vacuum as vacuum_platform, weather as weather_platform, ) from .const import DOMAIN, PLATFORMS, TemplateConfig @@ -90,50 +100,69 @@ CONFIG_SECTION_SCHEMA = vol.All( _backward_compat_schema, vol.Schema( { - vol.Optional(CONF_UNIQUE_ID): cv.string, - 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] - ), - vol.Optional(SENSOR_DOMAIN): vol.All( - cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] - ), - vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( - sensor_platform.LEGACY_SENSOR_SCHEMA - ), - vol.Optional(BINARY_SENSOR_DOMAIN): vol.All( - cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] - ), vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA ), - vol.Optional(SELECT_DOMAIN): vol.All( - cv.ensure_list, [select_platform.SELECT_SCHEMA] + vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, + vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( + sensor_platform.LEGACY_SENSOR_SCHEMA ), - vol.Optional(BUTTON_DOMAIN): vol.All( + vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + vol.Optional(DOMAIN_ALARM_CONTROL_PANEL): vol.All( + cv.ensure_list, + [alarm_control_panel_platform.ALARM_CONTROL_PANEL_SCHEMA], + ), + vol.Optional(DOMAIN_BINARY_SENSOR): vol.All( + cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] + ), + vol.Optional(DOMAIN_BUTTON): vol.All( cv.ensure_list, [button_platform.BUTTON_SCHEMA] ), - vol.Optional(IMAGE_DOMAIN): vol.All( + vol.Optional(DOMAIN_COVER): vol.All( + cv.ensure_list, [cover_platform.COVER_SCHEMA] + ), + vol.Optional(DOMAIN_FAN): vol.All( + cv.ensure_list, [fan_platform.FAN_SCHEMA] + ), + vol.Optional(DOMAIN_IMAGE): vol.All( cv.ensure_list, [image_platform.IMAGE_SCHEMA] ), - vol.Optional(LIGHT_DOMAIN): vol.All( + vol.Optional(DOMAIN_LIGHT): vol.All( cv.ensure_list, [light_platform.LIGHT_SCHEMA] ), - vol.Optional(WEATHER_DOMAIN): vol.All( - cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + vol.Optional(DOMAIN_LOCK): vol.All( + cv.ensure_list, [lock_platform.LOCK_SCHEMA] ), - vol.Optional(SWITCH_DOMAIN): vol.All( + vol.Optional(DOMAIN_NUMBER): vol.All( + cv.ensure_list, [number_platform.NUMBER_SCHEMA] + ), + vol.Optional(DOMAIN_SELECT): vol.All( + cv.ensure_list, [select_platform.SELECT_SCHEMA] + ), + vol.Optional(DOMAIN_SENSOR): vol.All( + cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] + ), + vol.Optional(DOMAIN_SWITCH): vol.All( cv.ensure_list, [switch_platform.SWITCH_SCHEMA] ), - vol.Optional(COVER_DOMAIN): vol.All( - cv.ensure_list, [cover_platform.COVER_SCHEMA] + vol.Optional(DOMAIN_VACUUM): vol.All( + cv.ensure_list, [vacuum_platform.VACUUM_SCHEMA] + ), + vol.Optional(DOMAIN_WEATHER): vol.All( + cv.ensure_list, [weather_platform.WEATHER_SCHEMA] ), }, ), - ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN, COVER_DOMAIN), + ensure_domains_do_not_have_trigger_or_action( + DOMAIN_ALARM_CONTROL_PANEL, + DOMAIN_BUTTON, + DOMAIN_FAN, + DOMAIN_LOCK, + DOMAIN_VACUUM, + ), ) TEMPLATE_BLUEPRINT_SCHEMA = vol.All( @@ -227,12 +256,12 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf for old_key, new_key, transform in ( ( CONF_SENSORS, - SENSOR_DOMAIN, + DOMAIN_SENSOR, sensor_platform.rewrite_legacy_to_modern_conf, ), ( CONF_BINARY_SENSORS, - BINARY_SENSOR_DOMAIN, + DOMAIN_BINARY_SENSOR, binary_sensor_platform.rewrite_legacy_to_modern_conf, ), ): diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index e15180173b4..0b2009e83e3 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator, Sequence import logging from typing import TYPE_CHECKING, Any @@ -11,6 +12,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DEVICE_CLASSES_SCHEMA, + DOMAIN as COVER_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA, CoverEntity, @@ -34,7 +36,9 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, @@ -43,6 +47,7 @@ from .template_entity import ( TemplateEntity, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -205,6 +210,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerCoverEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -213,49 +225,19 @@ async def async_setup_platform( ) -class CoverTemplate(TemplateEntity, CoverEntity): - """Representation of a Template cover.""" +class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): + """Representation of a template cover features.""" - _attr_should_poll = False + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" - def __init__( - self, - hass: HomeAssistant, - config: dict[str, Any], - unique_id, - ) -> None: - """Initialize the Template cover.""" - 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_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, - # therefore the base supported features will always include them. - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) - for action_id, supported_feature in ( - (OPEN_ACTION, 0), - (CLOSE_ACTION, 0), - (STOP_ACTION, CoverEntityFeature.STOP), - (POSITION_ACTION, CoverEntityFeature.SET_POSITION), - (TILT_ACTION, TILT_FEATURES), - ): - # 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 - optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or ( optimistic is None and not self._template and not self._position_template @@ -267,61 +249,60 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._is_closing = False self._tilt_value: int | None = None - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - if self._template: - self.add_template_attribute( - "_position", self._template, None, self._update_state - ) - if self._position_template: - self.add_template_attribute( - "_position", - self._position_template, - None, - self._update_position, - none_on_template_error=True, - ) - if self._tilt_template: - self.add_template_attribute( - "_tilt_value", - self._tilt_template, - None, - self._update_tilt, - none_on_template_error=True, - ) - super()._async_setup_templates() + # The config requires (open and close scripts) or a set position script, + # therefore the base supported features will always include them. + self._attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) - @callback - def _update_state(self, result): - super()._update_state(result) - if isinstance(result, TemplateError): - self._position = None - return + def _iterate_scripts( + self, config: dict[str, Any] + ) -> Generator[tuple[str, Sequence[dict[str, Any]], CoverEntityFeature | int]]: + for action_id, supported_feature in ( + (OPEN_ACTION, 0), + (CLOSE_ACTION, 0), + (STOP_ACTION, CoverEntityFeature.STOP), + (POSITION_ACTION, CoverEntityFeature.SET_POSITION), + (TILT_ACTION, TILT_FEATURES), + ): + if (action_config := config.get(action_id)) is not None: + yield (action_id, action_config, supported_feature) - state = str(result).lower() + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + if self._position is None: + return None - if state in _VALID_STATES: - if not self._position_template: - if state in ("true", OPEN_STATE): - self._position = 100 - else: - self._position = 0 + return self._position == 0 - self._is_opening = state == OPENING_STATE - self._is_closing = state == CLOSING_STATE - else: - _LOGGER.error( - "Received invalid cover is_on state: %s for entity %s. Expected: %s", - state, - self.entity_id, - ", ".join(_VALID_STATES), - ) - if not self._position_template: - self._position = None + @property + def is_opening(self) -> bool: + """Return if the cover is currently opening.""" + return self._is_opening - self._is_opening = False - self._is_closing = False + @property + def is_closing(self) -> bool: + """Return if the cover is currently closing.""" + return self._is_closing + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + if self._position_template or POSITION_ACTION in self._action_scripts: + return self._position + return None + + @property + def current_cover_tilt_position(self) -> int | None: + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._tilt_value @callback def _update_position(self, result): @@ -367,41 +348,30 @@ class CoverTemplate(TemplateEntity, CoverEntity): else: self._tilt_value = state - @property - def is_closed(self) -> bool | None: - """Return if the cover is closed.""" - if self._position is None: - return None + def _update_opening_and_closing(self, result: Any) -> None: + state = str(result).lower() - return self._position == 0 + if state in _VALID_STATES: + if not self._position_template: + if state in ("true", OPEN_STATE): + self._position = 100 + else: + self._position = 0 - @property - def is_opening(self) -> bool: - """Return if the cover is currently opening.""" - return self._is_opening + self._is_opening = state == OPENING_STATE + self._is_closing = state == CLOSING_STATE + else: + _LOGGER.error( + "Received invalid cover is_on state: %s for entity %s. Expected: %s", + state, + self.entity_id, + ", ".join(_VALID_STATES), + ) + if not self._position_template: + self._position = None - @property - def is_closing(self) -> bool: - """Return if the cover is currently closing.""" - return self._is_closing - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - if self._position_template or self._action_scripts.get(POSITION_ACTION): - return self._position - return None - - @property - def current_cover_tilt_position(self) -> int | None: - """Return current position of cover tilt. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._tilt_value + self._is_opening = False + self._is_closing = False async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" @@ -479,3 +449,127 @@ class CoverTemplate(TemplateEntity, CoverEntity): ) if self._tilt_optimistic: self.async_write_ha_state() + + +class CoverTemplate(TemplateEntity, AbstractTemplateCover): + """Representation of a Template cover.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict[str, Any], + unique_id, + ) -> None: + """Initialize the Template cover.""" + TemplateEntity.__init__( + self, hass, config=config, fallback_name=None, unique_id=unique_id + ) + AbstractTemplateCover.__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 + + for action_id, action_config, supported_feature in self._iterate_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template: + self.add_template_attribute( + "_position", self._template, None, self._update_state + ) + if self._position_template: + self.add_template_attribute( + "_position", + self._position_template, + None, + self._update_position, + none_on_template_error=True, + ) + if self._tilt_template: + self.add_template_attribute( + "_tilt_value", + self._tilt_template, + None, + self._update_tilt, + none_on_template_error=True, + ) + super()._async_setup_templates() + + @callback + def _update_state(self, result): + super()._update_state(result) + if isinstance(result, TemplateError): + self._position = None + return + + self._update_opening_and_closing(result) + + +class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover): + """Cover entity based on trigger data.""" + + domain = COVER_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateCover.__init__(self, config) + + # Render the _attr_name before initializing TriggerCoverEntity + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + for action_id, action_config, supported_feature in self._iterate_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + for key in (CONF_STATE, CONF_POSITION, CONF_TILT): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + @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_STATE, self._update_opening_and_closing), + (CONF_POSITION, self._update_position), + (CONF_TILT, self._update_tilt), + ): + if (rendered := self._rendered.get(key)) is not None: + updater(rendered) + write_ha_state = True + + if not self._optimistic: + 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() diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 7ec62891784..c353fca48df 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator, Sequence import logging from typing import TYPE_CHECKING, Any @@ -21,6 +22,8 @@ from homeassistant.components.fan import ( from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_NAME, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_ON, @@ -29,14 +32,18 @@ from homeassistant.const import ( ) 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 .entity import AbstractTemplateEntity from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) @@ -59,54 +66,121 @@ CONF_SET_PRESET_MODE_ACTION = "set_preset_mode" _VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE] +CONF_DIRECTION = "direction" +CONF_OSCILLATING = "oscillating" +CONF_PERCENTAGE = "percentage" +CONF_PRESET_MODE = "preset_mode" + +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_DIRECTION_TEMPLATE: CONF_DIRECTION, + CONF_OSCILLATING_TEMPLATE: CONF_OSCILLATING, + CONF_PERCENTAGE_TEMPLATE: CONF_PERCENTAGE, + CONF_PRESET_MODE_TEMPLATE: CONF_PRESET_MODE, + CONF_VALUE_TEMPLATE: CONF_STATE, +} + +DEFAULT_NAME = "Template Fan" + FAN_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_DIRECTION): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OSCILLATING): cv.template, + vol.Optional(CONF_PERCENTAGE): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_PRESET_MODE): cv.template, + vol.Optional(CONF_PRESET_MODES): cv.ensure_list, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), +) + +LEGACY_FAN_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { + vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, vol.Optional(CONF_PERCENTAGE_TEMPLATE): cv.template, vol.Optional(CONF_PRESET_MODE_TEMPLATE): cv.template, - vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, - vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_PRESET_MODES): cv.ensure_list, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), - vol.Optional(CONF_PRESET_MODES): cv.ensure_list, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema), ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_SCHEMA)} + {vol.Required(CONF_FANS): cv.schema_with_slug_keys(LEGACY_FAN_SCHEMA)} ) -async def _async_create_entities(hass: HomeAssistant, config): - """Create the Template Fans.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy fan configuration definitions to modern ones.""" fans = [] - for object_id, entity_config in config[CONF_FANS].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) + + fans.append(entity_conf) + + return fans + + +@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 fans.""" + fans = [] + + 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}" fans.append( TemplateFan( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return fans + async_add_entities(fans) async def async_setup_platform( @@ -116,54 +190,36 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - async_add_entities(await _async_create_entities(hass, config)) - - -class TemplateFan(TemplateEntity, FanEntity): - """A template fan component.""" - - _attr_should_poll = False - - def __init__( - self, - hass: HomeAssistant, - object_id, - config: dict[str, Any], - unique_id, - ) -> None: - """Initialize the fan.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_FANS]), + None, ) - self.hass = hass - 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 + return - self._template = config.get(CONF_VALUE_TEMPLATE) - self._percentage_template = config.get(CONF_PERCENTAGE_TEMPLATE) - self._preset_mode_template = config.get(CONF_PRESET_MODE_TEMPLATE) - self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE) - self._direction_template = config.get(CONF_DIRECTION_TEMPLATE) + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) - 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 + +class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): + """Representation of a template fan features.""" + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + + self._template = config.get(CONF_STATE) + self._percentage_template = config.get(CONF_PERCENTAGE) + self._preset_mode_template = config.get(CONF_PRESET_MODE) + self._oscillating_template = config.get(CONF_OSCILLATING) + self._direction_template = config.get(CONF_DIRECTION) self._state: bool | None = False self._percentage: int | None = None @@ -178,6 +234,20 @@ class TemplateFan(TemplateEntity, FanEntity): self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) self._attr_assumed_state = self._template is None + def _register_scripts( + self, config: dict[str, Any] + ) -> Generator[tuple[str, Sequence[dict[str, Any]], FanEntityFeature | int]]: + 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), + ): + if (action_config := config.get(action_id)) is not None: + yield (action_id, action_config, supported_feature) + @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" @@ -213,6 +283,92 @@ class TemplateFan(TemplateEntity, FanEntity): """Return the oscillation state.""" return self._direction + def _handle_state(self, result) -> None: + if isinstance(result, bool): + self._state = result + return + + if isinstance(result, str): + self._state = result.lower() in ("true", STATE_ON) + return + + self._state = False + + @callback + def _update_percentage(self, percentage): + # Validate percentage + try: + percentage = int(float(percentage)) + except (ValueError, TypeError): + _LOGGER.error( + "Received invalid percentage: %s for entity %s", + percentage, + self.entity_id, + ) + self._percentage = 0 + return + + if 0 <= percentage <= 100: + self._percentage = percentage + else: + _LOGGER.error( + "Received invalid percentage: %s for entity %s", + percentage, + self.entity_id, + ) + self._percentage = 0 + + @callback + def _update_preset_mode(self, preset_mode): + # Validate preset mode + preset_mode = str(preset_mode) + + if self.preset_modes and preset_mode in self.preset_modes: + self._preset_mode = preset_mode + elif preset_mode in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self._preset_mode = None + else: + _LOGGER.error( + "Received invalid preset_mode: %s for entity %s. Expected: %s", + preset_mode, + self.entity_id, + self.preset_mode, + ) + self._preset_mode = None + + @callback + def _update_oscillating(self, oscillating): + # Validate osc + if oscillating == "True" or oscillating is True: + self._oscillating = True + elif oscillating == "False" or oscillating is False: + self._oscillating = False + elif oscillating in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self._oscillating = None + else: + _LOGGER.error( + "Received invalid oscillating: %s for entity %s. Expected: True/False", + oscillating, + self.entity_id, + ) + self._oscillating = None + + @callback + def _update_direction(self, direction): + # Validate direction + if direction in _VALID_DIRECTIONS: + self._direction = direction + elif direction in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self._direction = None + else: + _LOGGER.error( + "Received invalid direction: %s for entity %s. Expected: %s", + direction, + self.entity_id, + ", ".join(_VALID_DIRECTIONS), + ) + self._direction = None + async def async_turn_on( self, percentage: int | None = None, @@ -231,7 +387,7 @@ class TemplateFan(TemplateEntity, FanEntity): if preset_mode is not None: await self.async_set_preset_mode(preset_mode) - elif percentage is not None: + if percentage is not None: await self.async_set_percentage(percentage) if self._template is None: @@ -319,6 +475,40 @@ class TemplateFan(TemplateEntity, FanEntity): ", ".join(_VALID_DIRECTIONS), ) + +class TemplateFan(TemplateEntity, AbstractTemplateFan): + """A template fan component.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict[str, Any], + unique_id, + ) -> None: + """Initialize the fan.""" + TemplateEntity.__init__( + self, hass, config=config, fallback_name=None, unique_id=unique_id + ) + AbstractTemplateFan.__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 + + self._attr_supported_features |= ( + FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON + ) + for action_id, action_config, supported_feature in self._register_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + @callback def _update_state(self, result): super()._update_state(result) @@ -326,15 +516,7 @@ class TemplateFan(TemplateEntity, FanEntity): self._state = None return - if isinstance(result, bool): - self._state = result - return - - if isinstance(result, str): - self._state = result.lower() in ("true", STATE_ON) - return - - self._state = False + self._handle_state(result) @callback def _async_setup_templates(self) -> None: @@ -377,78 +559,3 @@ class TemplateFan(TemplateEntity, FanEntity): none_on_template_error=True, ) super()._async_setup_templates() - - @callback - def _update_percentage(self, percentage): - # Validate percentage - try: - percentage = int(float(percentage)) - except (ValueError, TypeError): - _LOGGER.error( - "Received invalid percentage: %s for entity %s", - percentage, - self.entity_id, - ) - self._percentage = 0 - return - - if 0 <= percentage <= 100: - self._percentage = percentage - else: - _LOGGER.error( - "Received invalid percentage: %s for entity %s", - percentage, - self.entity_id, - ) - self._percentage = 0 - - @callback - def _update_preset_mode(self, preset_mode): - # Validate preset mode - preset_mode = str(preset_mode) - - if self.preset_modes and preset_mode in self.preset_modes: - self._preset_mode = preset_mode - elif preset_mode in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._preset_mode = None - else: - _LOGGER.error( - "Received invalid preset_mode: %s for entity %s. Expected: %s", - preset_mode, - self.entity_id, - self.preset_mode, - ) - self._preset_mode = None - - @callback - def _update_oscillating(self, oscillating): - # Validate osc - if oscillating == "True" or oscillating is True: - self._oscillating = True - elif oscillating == "False" or oscillating is False: - self._oscillating = False - elif oscillating in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._oscillating = None - else: - _LOGGER.error( - "Received invalid oscillating: %s for entity %s. Expected: True/False", - oscillating, - self.entity_id, - ) - self._oscillating = None - - @callback - def _update_direction(self, direction): - # Validate direction - if direction in _VALID_DIRECTIONS: - self._direction = direction - elif direction in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._direction = None - else: - _LOGGER.error( - "Received invalid direction: %s for entity %s. Expected: %s", - direction, - self.entity_id, - ", ".join(_VALID_DIRECTIONS), - ) - self._direction = None diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 3b64cca26b4..c852ee1808d 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -50,6 +50,7 @@ from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, @@ -272,16 +273,16 @@ async def async_setup_platform( ) -class AbstractTemplateLight(LightEntity): +class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" - def __init__( + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__( # pylint: disable=super-init-not-called self, config: dict[str, Any], initial_state: bool | None = False ) -> None: """Initialize the features.""" - self._registered_scripts: list[str] = [] - # Template attributes self._template = config.get(CONF_STATE) self._level_template = config.get(CONF_LEVEL) @@ -312,7 +313,7 @@ class AbstractTemplateLight(LightEntity): self._color_mode: ColorMode | None = None self._supported_color_modes: set[ColorMode] | None = None - def _register_scripts( + def _iterate_scripts( self, config: dict[str, Any] ) -> Generator[tuple[str, Sequence[dict[str, Any]], ColorMode | None]]: for action_id, color_mode in ( @@ -327,7 +328,6 @@ class AbstractTemplateLight(LightEntity): (CONF_RGBWW_ACTION, ColorMode.RGBWW), ): if (action_config := config.get(action_id)) is not None: - self._registered_scripts.append(action_id) yield (action_id, action_config, color_mode) @property @@ -522,17 +522,19 @@ class AbstractTemplateLight(LightEntity): if ( ATTR_COLOR_TEMP_KELVIN in kwargs - and (script := CONF_TEMPERATURE_ACTION) in self._registered_scripts + and (script := CONF_TEMPERATURE_ACTION) in self._action_scripts ): + kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] + common_params[ATTR_COLOR_TEMP_KELVIN] = kelvin common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( - kwargs[ATTR_COLOR_TEMP_KELVIN] + kelvin ) return (script, common_params) if ( ATTR_EFFECT in kwargs - and (script := CONF_EFFECT_ACTION) in self._registered_scripts + and (script := CONF_EFFECT_ACTION) in self._action_scripts ): assert self._effect_list is not None effect = kwargs[ATTR_EFFECT] @@ -551,7 +553,7 @@ class AbstractTemplateLight(LightEntity): if ( ATTR_HS_COLOR in kwargs - and (script := CONF_HS_ACTION) in self._registered_scripts + and (script := CONF_HS_ACTION) in self._action_scripts ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value @@ -562,7 +564,7 @@ class AbstractTemplateLight(LightEntity): if ( ATTR_RGBWW_COLOR in kwargs - and (script := CONF_RGBWW_ACTION) in self._registered_scripts + and (script := CONF_RGBWW_ACTION) in self._action_scripts ): rgbww_value = kwargs[ATTR_RGBWW_COLOR] common_params["rgbww"] = rgbww_value @@ -581,7 +583,7 @@ class AbstractTemplateLight(LightEntity): if ( ATTR_RGBW_COLOR in kwargs - and (script := CONF_RGBW_ACTION) in self._registered_scripts + and (script := CONF_RGBW_ACTION) in self._action_scripts ): rgbw_value = kwargs[ATTR_RGBW_COLOR] common_params["rgbw"] = rgbw_value @@ -599,7 +601,7 @@ class AbstractTemplateLight(LightEntity): if ( ATTR_RGB_COLOR in kwargs - and (script := CONF_RGB_ACTION) in self._registered_scripts + and (script := CONF_RGB_ACTION) in self._action_scripts ): rgb_value = kwargs[ATTR_RGB_COLOR] common_params["rgb"] = rgb_value @@ -611,7 +613,7 @@ class AbstractTemplateLight(LightEntity): if ( ATTR_BRIGHTNESS in kwargs - and (script := CONF_LEVEL_ACTION) in self._registered_scripts + and (script := CONF_LEVEL_ACTION) in self._action_scripts ): return (script, common_params) @@ -966,7 +968,7 @@ class LightTemplate(TemplateEntity, AbstractTemplateLight): assert name is not None color_modes = {ColorMode.ONOFF} - for action_id, action_config, color_mode in self._register_scripts(config): + for action_id, action_config, color_mode in self._iterate_scripts(config): self.add_script(action_id, action_config, name, DOMAIN) if color_mode: color_modes.add(color_mode) @@ -1180,7 +1182,7 @@ class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): self._parse_result.add(key) color_modes = {ColorMode.ONOFF} - for action_id, action_config, color_mode in self._register_scripts(config): + for action_id, action_config, color_mode in self._iterate_scripts(config): self.add_script(action_id, action_config, name, DOMAIN) if color_mode: color_modes.add(color_mode) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 12a3e66cb5e..25eac8c35e4 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator, Sequence from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -16,6 +17,7 @@ from homeassistant.const import ( ATTR_CODE, CONF_NAME, CONF_OPTIMISTIC, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) @@ -25,14 +27,19 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import CONF_PICTURE, DOMAIN +from .entity import AbstractTemplateEntity from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) CONF_CODE_FORMAT_TEMPLATE = "code_format_template" +CONF_CODE_FORMAT = "code_format" CONF_LOCK = "lock" CONF_UNLOCK = "unlock" CONF_OPEN = "open" @@ -40,26 +47,69 @@ CONF_OPEN = "open" DEFAULT_NAME = "Template Lock" DEFAULT_OPTIMISTIC = False +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_CODE_FORMAT_TEMPLATE: CONF_CODE_FORMAT, + CONF_VALUE_TEMPLATE: CONF_STATE, +} + +LOCK_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_CODE_FORMAT): cv.template, + vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PICTURE): cv.template, + vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), +) + + PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, - vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, - vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template, + vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, } ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -async def _async_create_entities( - hass: HomeAssistant, config: dict[str, Any] -) -> list[TemplateLock]: - """Create the Template lock.""" - config = rewrite_common_legacy_to_modern_conf(hass, config) - return [TemplateLock(hass, config, config.get(CONF_UNIQUE_ID))] +@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 fans.""" + fans = [] + + 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}" + + fans.append( + TemplateLock( + hass, + entity_conf, + unique_id, + ) + ) + + async_add_entities(fans) async def async_setup_platform( @@ -68,45 +118,50 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the template lock.""" - async_add_entities(await _async_create_entities(hass, config)) - - -class TemplateLock(TemplateEntity, LockEntity): - """Representation of a template lock.""" - - _attr_should_poll = False - - def __init__( - self, - hass: HomeAssistant, - config: dict[str, Any], - unique_id: str | None, - ) -> None: - """Initialize the lock.""" - super().__init__( - hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id + """Set up the template fans.""" + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + [rewrite_common_legacy_to_modern_conf(hass, config, LEGACY_FIELDS)], + None, ) - self._state: LockState | None = None - name = self._attr_name - if TYPE_CHECKING: - assert name is not None + return - self._state_template = config.get(CONF_VALUE_TEMPLATE) + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) + + +class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): + """Representation of a template lock features.""" + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + + self._state: LockState | None = None + self._state_template = config.get(CONF_STATE) + self._code_format_template = config.get(CONF_CODE_FORMAT) + self._code_format: str | None = None + self._code_format_template_error: TemplateError | None = None + self._optimistic = config.get(CONF_OPTIMISTIC) + self._attr_assumed_state = bool(self._optimistic) + + def _register_scripts( + self, config: dict[str, Any] + ) -> Generator[tuple[str, Sequence[dict[str, Any]], LockEntityFeature | int]]: for action_id, supported_feature in ( (CONF_LOCK, 0), (CONF_UNLOCK, 0), (CONF_OPEN, LockEntityFeature.OPEN), ): - # 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._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) - self._code_format: str | None = None - self._code_format_template_error: TemplateError | None = None - self._optimistic = config.get(CONF_OPTIMISTIC) - self._attr_assumed_state = bool(self._optimistic) + yield (action_id, action_config, supported_feature) @property def is_locked(self) -> bool: @@ -133,14 +188,12 @@ class TemplateLock(TemplateEntity, LockEntity): """Return true if lock is open.""" return self._state == LockState.OPEN - @callback - def _update_state(self, result: str | TemplateError) -> None: - """Update the state from the template.""" - super()._update_state(result) - if isinstance(result, TemplateError): - self._state = None - return + @property + def code_format(self) -> str | None: + """Regex for code format or None if no code is required.""" + return self._code_format + def _handle_state(self, result: Any) -> None: if isinstance(result, bool): self._state = LockState.LOCKED if result else LockState.UNLOCKED return @@ -167,28 +220,6 @@ class TemplateLock(TemplateEntity, LockEntity): self._state = None - @property - def code_format(self) -> str | None: - """Regex for code format or None if no code is required.""" - return self._code_format - - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - if TYPE_CHECKING: - assert self._state_template is not None - self.add_template_attribute( - "_state", self._state_template, None, self._update_state - ) - if self._code_format_template: - self.add_template_attribute( - "_code_format_template", - self._code_format_template, - None, - self._update_code_format, - ) - super()._async_setup_templates() - @callback def _update_code_format(self, render: str | TemplateError | None): """Update code format from the template.""" @@ -268,3 +299,57 @@ class TemplateLock(TemplateEntity, LockEntity): "cause": str(self._code_format_template_error), }, ) + + +class TemplateLock(TemplateEntity, AbstractTemplateLock): + """Representation of a template lock.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict[str, Any], + unique_id: str | None, + ) -> None: + """Initialize the lock.""" + TemplateEntity.__init__( + self, hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id + ) + AbstractTemplateLock.__init__(self, config) + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + + for action_id, action_config, supported_feature in self._register_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + @callback + def _update_state(self, result: str | TemplateError) -> None: + """Update the state from the template.""" + super()._update_state(result) + if isinstance(result, TemplateError): + self._state = None + return + + self._handle_state(result) + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if TYPE_CHECKING: + assert self._state_template is not None + self.add_template_attribute( + "_state", self._state_template, None, self._update_state + ) + if self._code_format_template: + self.add_template_attribute( + "_code_format_template", + self._code_format_template, + None, + self._update_code_format, + ) + super()._async_setup_templates() diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index 32bfd8ce02e..61c0bd1179a 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -2,7 +2,7 @@ "domain": "template", "name": "Template", "after_dependencies": ["group"], - "codeowners": ["@Petro31", "@PhracturedBlue", "@home-assistant/core"], + "codeowners": ["@Petro31", "@home-assistant/core"], "config_flow": true, "dependencies": ["blueprint"], "documentation": "https://www.home-assistant.io/integrations/template", diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 0b431d661cd..7f285b4929b 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -326,6 +326,7 @@ "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", @@ -348,6 +349,7 @@ "sensor_state_class": { "options": { "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]", "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%]" } diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 88708278758..f879c60ed9e 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -76,23 +76,35 @@ TEMPLATE_ENTITY_ICON_SCHEMA = vol.Schema( } ) -TEMPLATE_ENTITY_COMMON_SCHEMA = vol.Schema( +TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA = vol.Schema( { vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), - vol.Optional(CONF_AVAILABILITY): cv.template, - vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, } -).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) +) + +TEMPLATE_ENTITY_COMMON_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) +) def make_template_entity_common_schema(default_name: str) -> vol.Schema: """Return a schema with default name.""" - return vol.Schema( - { - vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), - vol.Optional(CONF_AVAILABILITY): cv.template, - } - ).extend(make_template_entity_base_schema(default_name).schema) + return ( + vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): cv.template, + } + ) + .extend(make_template_entity_base_schema(default_name).schema) + .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) + ) TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY = vol.Schema( @@ -268,7 +280,7 @@ class TemplateEntity(AbstractTemplateEntity): unique_id: str | None = None, ) -> None: """Template Entity.""" - super().__init__(hass) + AbstractTemplateEntity.__init__(self, hass) self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/template/trigger.py b/homeassistant/components/template/trigger.py index 44ac2d93051..c3e5a5d141f 100644 --- a/homeassistant/components/template/trigger.py +++ b/homeassistant/components/template/trigger.py @@ -48,6 +48,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" trigger_data = trigger_info["trigger_data"] + variables = trigger_info["variables"] or {} value_template: Template = config[CONF_VALUE_TEMPLATE] time_delta = config.get(CONF_FOR) delay_cancel = None @@ -56,9 +57,7 @@ async def async_attach_trigger( # Arm at setup if the template is already false. try: - if not result_as_boolean( - value_template.async_render(trigger_info["variables"]) - ): + if not result_as_boolean(value_template.async_render(variables)): armed = True except exceptions.TemplateError as ex: _LOGGER.warning( @@ -134,9 +133,12 @@ async def async_attach_trigger( call_action() return + data = {"trigger": template_variables} + period_variables = {**variables, **data} + try: period: timedelta = cv.positive_time_period( - template.render_complex(time_delta, {"trigger": template_variables}) + template.render_complex(time_delta, period_variables) ) except (exceptions.TemplateError, vol.Invalid) as ex: _LOGGER.error( @@ -150,7 +152,7 @@ async def async_attach_trigger( info = async_track_template_result( hass, - [TrackTemplate(value_template, trigger_info["variables"])], + [TrackTemplate(value_template, variables)], template_listener, ) unsub = info.async_remove diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1e18b06436a..f50751012b3 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator, Sequence import logging from typing import TYPE_CHECKING, Any @@ -24,21 +25,28 @@ from homeassistant.components.vacuum import ( from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_NAME, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, ) 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 .entity import AbstractTemplateEntity from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA, TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) @@ -46,8 +54,10 @@ from .template_entity import ( _LOGGER = logging.getLogger(__name__) CONF_VACUUMS = "vacuums" +CONF_BATTERY_LEVEL = "battery_level" CONF_BATTERY_LEVEL_TEMPLATE = "battery_level_template" CONF_FAN_SPEED_LIST = "fan_speeds" +CONF_FAN_SPEED = "fan_speed" CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}" @@ -60,24 +70,55 @@ _VALID_STATES = [ VacuumActivity.ERROR, ] +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_BATTERY_LEVEL_TEMPLATE: CONF_BATTERY_LEVEL, + CONF_FAN_SPEED_TEMPLATE: CONF_FAN_SPEED, + CONF_VALUE_TEMPLATE: CONF_STATE, +} + VACUUM_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_BATTERY_LEVEL): cv.template, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, + vol.Optional(CONF_FAN_SPEED): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, + vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), +) + +LEGACY_VACUUM_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_BATTERY_LEVEL_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED_TEMPLATE): cv.template, - vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, } ) .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY.schema) @@ -85,28 +126,56 @@ VACUUM_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_VACUUMS): vol.Schema({cv.slug: VACUUM_SCHEMA})} + {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(LEGACY_VACUUM_SCHEMA)} ) -async def _async_create_entities(hass: HomeAssistant, config: ConfigType): - """Create the Template Vacuums.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" vacuums = [] - for object_id, entity_config in config[CONF_VACUUMS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_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) + + vacuums.append(entity_conf) + + return vacuums + + +@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.""" + vacuums = [] + + 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}" vacuums.append( TemplateVacuum( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return vacuums + async_add_entities(vacuums) async def async_setup_platform( @@ -115,40 +184,45 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the template vacuums.""" - async_add_entities(await _async_create_entities(hass, config)) - - -class TemplateVacuum(TemplateEntity, StateVacuumEntity): - """A template vacuum component.""" - - _attr_should_poll = False - - def __init__( - self, - hass: HomeAssistant, - object_id, - config: ConfigType, - unique_id, - ) -> None: - """Initialize the vacuum.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id + """Set up the Template cover.""" + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_VACUUMS]), + 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 + return - self._template = config.get(CONF_VALUE_TEMPLATE) - self._battery_level_template = config.get(CONF_BATTERY_LEVEL_TEMPLATE) - self._fan_speed_template = config.get(CONF_FAN_SPEED_TEMPLATE) - self._attr_supported_features = ( - VacuumEntityFeature.START | VacuumEntityFeature.STATE - ) + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) + +class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): + """Representation of a template vacuum features.""" + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + self._template = config.get(CONF_STATE) + self._battery_level_template = config.get(CONF_BATTERY_LEVEL) + self._fan_speed_template = config.get(CONF_FAN_SPEED) + + self._state = None + self._battery_level = None + self._attr_fan_speed = None + + # List of valid fan speeds + self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] + + def _register_scripts( + self, config: dict[str, Any] + ) -> Generator[tuple[str, Sequence[dict[str, Any]], VacuumEntityFeature | int]]: for action_id, supported_feature in ( (SERVICE_START, 0), (SERVICE_PAUSE, VacuumEntityFeature.PAUSE), @@ -158,26 +232,29 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), ): - # 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 = None - self._battery_level = None - self._attr_fan_speed = None - - if self._battery_level_template: - self._attr_supported_features |= VacuumEntityFeature.BATTERY - - # List of valid fan speeds - self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] + yield (action_id, action_config, supported_feature) @property def activity(self) -> VacuumActivity | None: """Return the status of the vacuum cleaner.""" return self._state + def _handle_state(self, result: Any) -> None: + # Validate state + if result in _VALID_STATES: + self._state = result + elif result == STATE_UNKNOWN: + self._state = None + else: + _LOGGER.error( + "Received invalid vacuum state: %s for entity %s. Expected: %s", + result, + self.entity_id, + ", ".join(_VALID_STATES), + ) + self._state = None + async def async_start(self) -> None: """Start or resume the cleaning task.""" await self.async_run_script( @@ -225,54 +302,6 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context ) - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - if self._template is not None: - self.add_template_attribute( - "_state", self._template, None, self._update_state - ) - if self._fan_speed_template is not None: - self.add_template_attribute( - "_fan_speed", - self._fan_speed_template, - None, - self._update_fan_speed, - ) - if self._battery_level_template is not None: - self.add_template_attribute( - "_battery_level", - self._battery_level_template, - None, - self._update_battery_level, - none_on_template_error=True, - ) - super()._async_setup_templates() - - @callback - def _update_state(self, result): - super()._update_state(result) - if isinstance(result, TemplateError): - # This is legacy behavior - self._state = STATE_UNKNOWN - if not self._availability_template: - self._attr_available = True - return - - # Validate state - if result in _VALID_STATES: - self._state = result - elif result == STATE_UNKNOWN: - self._state = None - else: - _LOGGER.error( - "Received invalid vacuum state: %s for entity %s. Expected: %s", - result, - self.entity_id, - ", ".join(_VALID_STATES), - ) - self._state = None - @callback def _update_battery_level(self, battery_level): try: @@ -310,3 +339,76 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self._attr_fan_speed_list, ) self._attr_fan_speed = None + + +class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): + """A template vacuum component.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + unique_id, + ) -> None: + """Initialize the vacuum.""" + TemplateEntity.__init__( + self, hass, config=config, fallback_name=None, unique_id=unique_id + ) + AbstractTemplateVacuum.__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 + + self._attr_supported_features = ( + VacuumEntityFeature.START | VacuumEntityFeature.STATE + ) + for action_id, action_config, supported_feature in self._register_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + if self._battery_level_template: + self._attr_supported_features |= VacuumEntityFeature.BATTERY + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template is not None: + self.add_template_attribute( + "_state", self._template, None, self._update_state + ) + if self._fan_speed_template is not None: + self.add_template_attribute( + "_fan_speed", + self._fan_speed_template, + None, + self._update_fan_speed, + ) + if self._battery_level_template is not None: + self.add_template_attribute( + "_battery_level", + self._battery_level_template, + None, + self._update_battery_level, + none_on_template_error=True, + ) + super()._async_setup_templates() + + @callback + def _update_state(self, result): + super()._update_state(result) + if isinstance(result, TemplateError): + # This is legacy behavior + self._state = STATE_UNKNOWN + if not self._availability_template: + self._attr_available = True + return + + self._handle_state(result) diff --git a/homeassistant/components/tensorflow/__init__.py b/homeassistant/components/tensorflow/__init__.py index 00a695d6aa8..7ed20cbe4b6 100644 --- a/homeassistant/components/tensorflow/__init__.py +++ b/homeassistant/components/tensorflow/__init__.py @@ -1 +1,4 @@ """The tensorflow component.""" + +DOMAIN = "tensorflow" +CONF_GRAPH = "graph" diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index 15addd3513d..05be56d444d 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -7,6 +7,7 @@ import logging import os import sys import time +from typing import Any import numpy as np from PIL import Image, ImageDraw, UnidentifiedImageError @@ -25,15 +26,21 @@ from homeassistant.const import ( CONF_SOURCE, EVENT_HOMEASSISTANT_START, ) -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + split_entity_id, +) from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.pil import draw_box +from . import CONF_GRAPH, DOMAIN + os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" -DOMAIN = "tensorflow" _LOGGER = logging.getLogger(__name__) ATTR_MATCHES = "matches" @@ -46,7 +53,6 @@ CONF_BOTTOM = "bottom" CONF_CATEGORIES = "categories" CONF_CATEGORY = "category" CONF_FILE_OUT = "file_out" -CONF_GRAPH = "graph" CONF_LABELS = "labels" CONF_LABEL_OFFSET = "label_offset" CONF_LEFT = "left" @@ -54,6 +60,8 @@ CONF_MODEL_DIR = "model_dir" CONF_RIGHT = "right" CONF_TOP = "top" +_DEFAULT_AREA = (0.0, 0.0, 1.0, 1.0) + AREA_SCHEMA = vol.Schema( { vol.Optional(CONF_BOTTOM, default=1): cv.small_float, @@ -107,6 +115,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the TensorFlow image processing platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Tensorflow", + }, + ) + model_config = config[CONF_MODEL] model_dir = model_config.get(CONF_MODEL_DIR) or hass.config.path("tensorflow") labels = model_config.get(CONF_LABELS) or hass.config.path( @@ -189,19 +212,21 @@ def setup_platform( hass.bus.listen_once(EVENT_HOMEASSISTANT_START, tensorflow_hass_start) - category_index = label_map_util.create_category_index_from_labelmap( - labels, use_display_name=True + category_index: dict[int, dict[str, Any]] = ( + label_map_util.create_category_index_from_labelmap( + labels, use_display_name=True + ) ) + source: list[dict[str, str]] = config[CONF_SOURCE] add_entities( TensorFlowImageProcessor( - hass, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), category_index, config, ) - for camera in config[CONF_SOURCE] + for camera in source ) @@ -210,78 +235,66 @@ class TensorFlowImageProcessor(ImageProcessingEntity): def __init__( self, - hass, - camera_entity, - name, - category_index, - config, - ): + camera_entity: str, + name: str | None, + category_index: dict[int, dict[str, Any]], + config: ConfigType, + ) -> None: """Initialize the TensorFlow entity.""" - model_config = config.get(CONF_MODEL) - self.hass = hass - self._camera_entity = camera_entity + model_config: dict[str, Any] = config[CONF_MODEL] + self._attr_camera_entity = camera_entity if name: - self._name = name + self._attr_name = name else: - self._name = f"TensorFlow {split_entity_id(camera_entity)[1]}" + self._attr_name = f"TensorFlow {split_entity_id(camera_entity)[1]}" self._category_index = category_index self._min_confidence = config.get(CONF_CONFIDENCE) self._file_out = config.get(CONF_FILE_OUT) # handle categories and specific detection areas self._label_id_offset = model_config.get(CONF_LABEL_OFFSET) - categories = model_config.get(CONF_CATEGORIES) + categories: list[str | dict[str, Any]] = model_config[CONF_CATEGORIES] self._include_categories = [] - self._category_areas = {} + self._category_areas: dict[str, tuple[float, float, float, float]] = {} for category in categories: if isinstance(category, dict): - category_name = category.get(CONF_CATEGORY) + category_name: str = category[CONF_CATEGORY] category_area = category.get(CONF_AREA) self._include_categories.append(category_name) - self._category_areas[category_name] = [0, 0, 1, 1] + self._category_areas[category_name] = _DEFAULT_AREA if category_area: - self._category_areas[category_name] = [ - category_area.get(CONF_TOP), - category_area.get(CONF_LEFT), - category_area.get(CONF_BOTTOM), - category_area.get(CONF_RIGHT), - ] + self._category_areas[category_name] = ( + category_area[CONF_TOP], + category_area[CONF_LEFT], + category_area[CONF_BOTTOM], + category_area[CONF_RIGHT], + ) else: self._include_categories.append(category) - self._category_areas[category] = [0, 0, 1, 1] + self._category_areas[category] = _DEFAULT_AREA # Handle global detection area - self._area = [0, 0, 1, 1] + self._area = _DEFAULT_AREA if area_config := model_config.get(CONF_AREA): - self._area = [ - area_config.get(CONF_TOP), - area_config.get(CONF_LEFT), - area_config.get(CONF_BOTTOM), - area_config.get(CONF_RIGHT), - ] + self._area = ( + area_config[CONF_TOP], + area_config[CONF_LEFT], + area_config[CONF_BOTTOM], + area_config[CONF_RIGHT], + ) - self._matches = {} + self._matches: dict[str, list[dict[str, Any]]] = {} self._total_matches = 0 self._last_image = None - self._process_time = 0 + self._process_time = 0.0 @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera_entity - - @property - def name(self): - """Return the name of the image processor.""" - return self._name - - @property - def state(self): + def state(self) -> int: """Return the state of the entity.""" return self._total_matches @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" return { ATTR_MATCHES: self._matches, @@ -292,25 +305,25 @@ class TensorFlowImageProcessor(ImageProcessingEntity): ATTR_PROCESS_TIME: self._process_time, } - def _save_image(self, image, matches, paths): + def _save_image( + self, image: bytes, matches: dict[str, list[dict[str, Any]]], paths: list[str] + ) -> None: img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") img_width, img_height = img.size draw = ImageDraw.Draw(img) # Draw custom global region/area - if self._area != [0, 0, 1, 1]: + if self._area != _DEFAULT_AREA: draw_box( draw, self._area, img_width, img_height, "Detection Area", (0, 255, 255) ) for category, values in matches.items(): # Draw custom category regions/areas - if category in self._category_areas and self._category_areas[category] != [ - 0, - 0, - 1, - 1, - ]: + if ( + category in self._category_areas + and self._category_areas[category] != _DEFAULT_AREA + ): label = f"{category.capitalize()} Detection Area" draw_box( draw, @@ -333,7 +346,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): os.makedirs(os.path.dirname(path), exist_ok=True) img.save(path) - def process_image(self, image): + def process_image(self, image: bytes) -> None: """Process the image.""" if not (model := self.hass.data[DOMAIN][CONF_MODEL]): _LOGGER.debug("Model not yet ready") @@ -352,7 +365,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): except UnidentifiedImageError: _LOGGER.warning("Unable to process image, bad data") return - img.thumbnail((460, 460), Image.ANTIALIAS) + img.thumbnail((460, 460), Image.Resampling.LANCZOS) img_width, img_height = img.size inp = ( np.array(img.getdata()) @@ -371,7 +384,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): detections["detection_classes"][0].numpy() + self._label_id_offset ).astype(int) - matches = {} + matches: dict[str, list[dict[str, Any]]] = {} total_matches = 0 for box, score, obj_class in zip(boxes, scores, classes, strict=False): score = score * 100 @@ -416,9 +429,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): paths = [] for path_template in self._file_out: if isinstance(path_template, template.Template): - paths.append( - path_template.render(camera_entity=self._camera_entity) - ) + paths.append(path_template.render(camera_entity=self.camera_entity)) else: paths.append(path_template) self._save_image(image, matches, paths) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 53c8e7d554c..8f5ba1468a5 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.0.17"] + "requirements": ["tesla-fleet-api==1.1.1"] } diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 9efa55de54f..5d9a757b9e6 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER, MODELS +from .const import DOMAIN, LOGGER from .coordinator import ( TeslemetryEnergyHistoryCoordinator, TeslemetryEnergySiteInfoCoordinator, @@ -95,13 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - energysites: list[TeslemetryEnergyData] = [] # Create the stream - stream = TeslemetryStream( - session, - access_token, - server=f"{region.lower()}.teslemetry.com", - parse_timestamp=True, - manual=True, - ) + stream: TeslemetryStream | None = None for product in products: if ( @@ -119,10 +113,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - manufacturer="Tesla", configuration_url="https://teslemetry.com/console", name=product["display_name"], - model=MODELS.get(vin[3]), + model=api.model, serial_number=vin, ) + # Create stream if required + if not stream: + stream = TeslemetryStream( + session, + access_token, + server=f"{region.lower()}.teslemetry.com", + parse_timestamp=True, + manual=True, + ) + remove_listener = stream.async_add_listener( create_handle_vehicle_stream(vin, coordinator), {"vin": vin}, @@ -237,10 +241,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) # Setup Platforms - entry.runtime_data = TeslemetryData(vehicles, energysites, scopes) + entry.runtime_data = TeslemetryData(vehicles, energysites, scopes, stream) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_create_background_task(hass, stream.listen(), "Teslemetry Stream") + if stream: + 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 a62dbe1e00f..a32c5fea40e 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -24,7 +24,7 @@ from .const import TeslemetryState from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -58,26 +58,28 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="state", polling=True, - polling_value_fn=lambda x: x == TeslemetryState.ONLINE, - streaming_listener=lambda x, y: x.listen_State(y), + polling_value_fn=lambda value: value == TeslemetryState.ONLINE, + streaming_listener=lambda vehicle, callback: vehicle.listen_State(callback), device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( key="cellular", - streaming_listener=lambda x, y: x.listen_Cellular(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_Cellular(callback), device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="wifi", - streaming_listener=lambda x, y: x.listen_Wifi(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_Wifi(callback), device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="charge_state_battery_heater_on", polling=True, - streaming_listener=lambda x, y: x.listen_BatteryHeaterOn(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_BatteryHeaterOn( + callback + ), device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -85,8 +87,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_charger_phases", polling=True, - streaming_listener=lambda x, y: x.listen_ChargerPhases( - lambda z: y(None if z is None else z > 1) + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargerPhases( + lambda value: callback(None if value is None else value > 1) ), polling_value_fn=lambda x: cast(int, x) > 1, entity_registry_enabled_default=False, @@ -94,7 +96,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_preconditioning_enabled", polling=True, - streaming_listener=lambda x, y: x.listen_PreconditioningEnabled(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_PreconditioningEnabled(callback), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -107,7 +110,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_scheduled_charging_pending", polling=True, - streaming_listener=lambda x, y: x.listen_ScheduledChargingPending(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_ScheduledChargingPending(callback), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -121,6 +125,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( key="charge_state_conn_charge_cable", polling=True, polling_value_fn=lambda x: x != "", + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( + lambda value: callback(value != "Unknown") + ), entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), @@ -175,8 +182,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="vehicle_state_fd_window", polling=True, - streaming_listener=lambda x, y: x.listen_FrontDriverWindow( - lambda z: y(WINDOW_STATES.get(z)) + streaming_listener=lambda vehicle, callback: vehicle.listen_FrontDriverWindow( + lambda value: callback(None if value is None else WINDOW_STATES.get(value)) ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, @@ -184,8 +191,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="vehicle_state_fp_window", polling=True, - streaming_listener=lambda x, y: x.listen_FrontPassengerWindow( - lambda z: y(WINDOW_STATES.get(z)) + streaming_listener=lambda vehicle, + callback: vehicle.listen_FrontPassengerWindow( + lambda value: callback(None if value is None else WINDOW_STATES.get(value)) ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, @@ -193,8 +201,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="vehicle_state_rd_window", polling=True, - streaming_listener=lambda x, y: x.listen_RearDriverWindow( - lambda z: y(WINDOW_STATES.get(z)) + streaming_listener=lambda vehicle, callback: vehicle.listen_RearDriverWindow( + lambda value: callback(None if value is None else WINDOW_STATES.get(value)) ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, @@ -202,8 +210,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="vehicle_state_rp_window", polling=True, - streaming_listener=lambda x, y: x.listen_RearPassengerWindow( - lambda z: y(WINDOW_STATES.get(z)) + streaming_listener=lambda vehicle, callback: vehicle.listen_RearPassengerWindow( + lambda value: callback(None if value is None else WINDOW_STATES.get(value)) ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, @@ -212,217 +220,287 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( key="vehicle_state_df", polling=True, device_class=BinarySensorDeviceClass.DOOR, - streaming_listener=lambda x, y: x.listen_FrontDriverDoor(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_FrontDriverDoor( + callback + ), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dr", polling=True, device_class=BinarySensorDeviceClass.DOOR, - streaming_listener=lambda x, y: x.listen_RearDriverDoor(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_RearDriverDoor( + callback + ), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pf", polling=True, device_class=BinarySensorDeviceClass.DOOR, - streaming_listener=lambda x, y: x.listen_FrontPassengerDoor(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_FrontPassengerDoor( + callback + ), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pr", polling=True, device_class=BinarySensorDeviceClass.DOOR, - streaming_listener=lambda x, y: x.listen_RearPassengerDoor(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_RearPassengerDoor( + callback + ), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="automatic_blind_spot_camera", - streaming_listener=lambda x, y: x.listen_AutomaticBlindSpotCamera(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_AutomaticBlindSpotCamera(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="automatic_emergency_braking_off", - streaming_listener=lambda x, y: x.listen_AutomaticEmergencyBrakingOff(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_AutomaticEmergencyBrakingOff(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="blind_spot_collision_warning_chime", - streaming_listener=lambda x, y: x.listen_BlindSpotCollisionWarningChime(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_BlindSpotCollisionWarningChime(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="bms_full_charge_complete", - streaming_listener=lambda x, y: x.listen_BmsFullchargecomplete(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_BmsFullchargecomplete(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="brake_pedal", - streaming_listener=lambda x, y: x.listen_BrakePedal(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_BrakePedal( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_port_cold_weather_mode", - streaming_listener=lambda x, y: x.listen_ChargePortColdWeatherMode(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_ChargePortColdWeatherMode(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="service_mode", - streaming_listener=lambda x, y: x.listen_ServiceMode(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_ServiceMode( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="pin_to_drive_enabled", - streaming_listener=lambda x, y: x.listen_PinToDriveEnabled(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_PinToDriveEnabled( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="drive_rail", - streaming_listener=lambda x, y: x.listen_DriveRail(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_DriveRail(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="driver_seat_belt", - streaming_listener=lambda x, y: x.listen_DriverSeatBelt(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_DriverSeatBelt( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="driver_seat_occupied", - streaming_listener=lambda x, y: x.listen_DriverSeatOccupied(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_DriverSeatOccupied( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="passenger_seat_belt", - streaming_listener=lambda x, y: x.listen_PassengerSeatBelt(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_PassengerSeatBelt( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="fast_charger_present", - streaming_listener=lambda x, y: x.listen_FastChargerPresent(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_FastChargerPresent( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="gps_state", - streaming_listener=lambda x, y: x.listen_GpsState(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_GpsState(callback), entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( key="guest_mode_enabled", - streaming_listener=lambda x, y: x.listen_GuestModeEnabled(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_GuestModeEnabled( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="dc_dc_enable", - streaming_listener=lambda x, y: x.listen_DCDCEnable(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_DCDCEnable( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="emergency_lane_departure_avoidance", - streaming_listener=lambda x, y: x.listen_EmergencyLaneDepartureAvoidance(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_EmergencyLaneDepartureAvoidance(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="supercharger_session_trip_planner", - streaming_listener=lambda x, y: x.listen_SuperchargerSessionTripPlanner(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_SuperchargerSessionTripPlanner(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="wiper_heat_enabled", - streaming_listener=lambda x, y: x.listen_WiperHeatEnabled(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_WiperHeatEnabled( + callback + ), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="rear_display_hvac_enabled", - streaming_listener=lambda x, y: x.listen_RearDisplayHvacEnabled(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_RearDisplayHvacEnabled(callback), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="offroad_lightbar_present", - streaming_listener=lambda x, y: x.listen_OffroadLightbarPresent(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_OffroadLightbarPresent(callback), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="homelink_nearby", - streaming_listener=lambda x, y: x.listen_HomelinkNearby(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_HomelinkNearby( + callback + ), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="europe_vehicle", - streaming_listener=lambda x, y: x.listen_EuropeVehicle(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_EuropeVehicle( + callback + ), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="right_hand_drive", - streaming_listener=lambda x, y: x.listen_RightHandDrive(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_RightHandDrive( + callback + ), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="located_at_home", - streaming_listener=lambda x, y: x.listen_LocatedAtHome(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_LocatedAtHome( + callback + ), streaming_firmware="2024.44.32", ), TeslemetryBinarySensorEntityDescription( key="located_at_work", - streaming_listener=lambda x, y: x.listen_LocatedAtWork(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_LocatedAtWork( + callback + ), streaming_firmware="2024.44.32", ), TeslemetryBinarySensorEntityDescription( key="located_at_favorite", - streaming_listener=lambda x, y: x.listen_LocatedAtFavorite(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_LocatedAtFavorite( + callback + ), streaming_firmware="2024.44.32", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_enable_request", - streaming_listener=lambda x, y: x.listen_ChargeEnableRequest(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargeEnableRequest( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="defrost_for_preconditioning", - streaming_listener=lambda x, y: x.listen_DefrostForPreconditioning(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_DefrostForPreconditioning(callback), entity_registry_enabled_default=False, streaming_firmware="2024.44.25", ), + TeslemetryBinarySensorEntityDescription( + key="lights_hazards_active", + streaming_listener=lambda x, y: x.listen_LightsHazardsActive(y), + entity_registry_enabled_default=False, + streaming_firmware="2025.2.6", + ), TeslemetryBinarySensorEntityDescription( key="lights_high_beams", - streaming_listener=lambda x, y: x.listen_LightsHighBeams(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_LightsHighBeams( + callback + ), entity_registry_enabled_default=False, streaming_firmware="2025.2.6", ), TeslemetryBinarySensorEntityDescription( key="seat_vent_enabled", - streaming_listener=lambda x, y: x.listen_SeatVentEnabled(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_SeatVentEnabled( + callback + ), entity_registry_enabled_default=False, streaming_firmware="2025.2.6", ), TeslemetryBinarySensorEntityDescription( key="speed_limit_mode", - streaming_listener=lambda x, y: x.listen_SpeedLimitMode(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_SpeedLimitMode( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="remote_start_enabled", - streaming_listener=lambda x, y: x.listen_RemoteStartEnabled(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_RemoteStartEnabled( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="hvil", - streaming_listener=lambda x, y: x.listen_Hvil(lambda z: y(z == "Fault")), + streaming_listener=lambda vehicle, callback: vehicle.listen_Hvil( + lambda value: callback(None if value is None else value == "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")), + streaming_listener=lambda vehicle, callback: vehicle.listen_HvacAutoMode( + lambda value: callback(None if value is None else value == "On") + ), entity_registry_enabled_default=False, ), ) @@ -431,7 +509,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( ENERGY_LIVE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="grid_status", - polling_value_fn=lambda x: x == "Active", + polling_value_fn=lambda value: value == "Active", device_class=BinarySensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -494,7 +572,7 @@ async def async_setup_entry( class TeslemetryVehiclePollingBinarySensorEntity( - TeslemetryVehicleEntity, BinarySensorEntity + TeslemetryVehiclePollingEntity, BinarySensorEntity ): """Base class for Teslemetry vehicle binary sensors.""" diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index 4ca2fd9b166..cf1d6157ec1 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -7,13 +7,14 @@ from dataclasses import dataclass from typing import Any from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import TeslemetryVehiclePollingEntity from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData @@ -50,8 +51,8 @@ DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( key="homelink", func=lambda self: handle_vehicle_command( self.api.trigger_homelink( - lat=self.coordinator.data["drive_state_latitude"], - lon=self.coordinator.data["drive_state_longitude"], + lat=self.hass.config.latitude, + lon=self.hass.config.longitude, ) ), ), @@ -73,9 +74,10 @@ async def async_setup_entry( ) -class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity): +class TeslemetryButtonEntity(TeslemetryVehiclePollingEntity, ButtonEntity): """Base class for Teslemetry buttons.""" + api: Vehicle entity_description: TeslemetryButtonEntityDescription def __init__( diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index c1c8fcd2f73..1bc52b23026 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -30,7 +30,7 @@ from . import TeslemetryConfigEntry from .const import DOMAIN, TeslemetryClimateSide from .entity import ( TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_vehicle_command @@ -64,7 +64,7 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingClimateEntity( + TeslemetryVehiclePollingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" @@ -74,7 +74,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryPollingCabinOverheatProtectionEntity( + TeslemetryVehiclePollingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" @@ -91,7 +91,6 @@ class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity): """Vehicle Climate Control.""" api: Vehicle - _attr_precision = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] @@ -178,7 +177,9 @@ class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity): self.async_write_ha_state() -class TeslemetryPollingClimateEntity(TeslemetryClimateEntity, TeslemetryVehicleEntity): +class TeslemetryVehiclePollingClimateEntity( + TeslemetryClimateEntity, TeslemetryVehiclePollingEntity +): """Polling vehicle climate entity.""" _attr_supported_features = ( @@ -370,7 +371,6 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryRootEntity, ClimateEntit """Vehicle Cabin Overheat Protection.""" api: Vehicle - _attr_precision = PRECISION_WHOLE _attr_target_temperature_step = 5 _attr_min_temp = 30 @@ -430,8 +430,8 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryRootEntity, ClimateEntit self.async_write_ha_state() -class TeslemetryPollingCabinOverheatProtectionEntity( - TeslemetryVehicleEntity, TeslemetryCabinOverheatProtectionEntity +class TeslemetryVehiclePollingCabinOverheatProtectionEntity( + TeslemetryVehiclePollingEntity, TeslemetryCabinOverheatProtectionEntity ): """Vehicle Cabin Overheat Protection.""" diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py index 01c6c33f505..ebda486aedf 100644 --- a/homeassistant/components/teslemetry/const.py +++ b/homeassistant/components/teslemetry/const.py @@ -9,13 +9,6 @@ DOMAIN = "teslemetry" LOGGER = logging.getLogger(__package__) -MODELS = { - "S": "Model S", - "3": "Model 3", - "X": "Model X", - "Y": "Model Y", -} - ENERGY_HISTORY_FIELDS = [ "solar_energy_exported", "generator_energy_exported", diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 406b9cb2d84..c31bdc2a34e 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -195,9 +195,13 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise UpdateFailed(e.message) from e # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: - output[key] += period.get(key, 0) + if key in period: + if output[key] is None: + output[key] = period[key] + else: + output[key] += period[key] return output diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index cde1d3f7d4f..f6ff71ab0cc 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -6,6 +6,7 @@ from itertools import chain from typing import Any from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand +from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import Signal from teslemetry_stream.const import WindowState @@ -21,7 +22,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .entity import ( TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_vehicle_command @@ -43,13 +44,15 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingWindowEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingWindowEntity(vehicle, entry.runtime_data.scopes) if vehicle.api.pre2021 or vehicle.firmware < "2024.26" else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryPollingChargePortEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingChargePortEntity( + vehicle, entry.runtime_data.scopes + ) if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" else TeslemetryStreamingChargePortEntity( vehicle, entry.runtime_data.scopes @@ -57,7 +60,9 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryPollingFrontTrunkEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingFrontTrunkEntity( + vehicle, entry.runtime_data.scopes + ) if vehicle.api.pre2021 or vehicle.firmware < "2024.26" else TeslemetryStreamingFrontTrunkEntity( vehicle, entry.runtime_data.scopes @@ -65,7 +70,9 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryPollingRearTrunkEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingRearTrunkEntity( + vehicle, entry.runtime_data.scopes + ) if vehicle.api.pre2021 or vehicle.firmware < "2024.26" else TeslemetryStreamingRearTrunkEntity( vehicle, entry.runtime_data.scopes @@ -97,6 +104,7 @@ class CoverRestoreEntity(RestoreEntity, CoverEntity): class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity): """Base class for window cover entities.""" + api: Vehicle _attr_device_class = CoverDeviceClass.WINDOW _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -121,8 +129,8 @@ class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity): self.async_write_ha_state() -class TeslemetryPollingWindowEntity( - TeslemetryVehicleEntity, TeslemetryWindowEntity, CoverEntity +class TeslemetryVehiclePollingWindowEntity( + TeslemetryVehiclePollingEntity, TeslemetryWindowEntity, CoverEntity ): """Polling cover entity for windows.""" @@ -193,14 +201,22 @@ class TeslemetryStreamingWindowEntity( def _handle_stream_update(self, data) -> None: """Update the entity attributes.""" - if value := data.get(Signal.FD_WINDOW): - self.fd = WindowState.get(value) == "closed" - if value := data.get(Signal.FP_WINDOW): - self.fp = WindowState.get(value) == "closed" - if value := data.get(Signal.RD_WINDOW): - self.rd = WindowState.get(value) == "closed" - if value := data.get(Signal.RP_WINDOW): - self.rp = WindowState.get(value) == "closed" + change = False + if value := data["data"].get(Signal.FD_WINDOW): + self.fd = WindowState.get(value) == "Closed" + change = True + if value := data["data"].get(Signal.FP_WINDOW): + self.fp = WindowState.get(value) == "Closed" + change = True + if value := data["data"].get(Signal.RD_WINDOW): + self.rd = WindowState.get(value) == "Closed" + change = True + if value := data["data"].get(Signal.RP_WINDOW): + self.rp = WindowState.get(value) == "Closed" + change = True + + if not change: + return if False in (self.fd, self.fp, self.rd, self.rp): self._attr_is_closed = False @@ -218,6 +234,7 @@ class TeslemetryChargePortEntity( ): """Base class for for charge port cover entities.""" + api: Vehicle _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -238,8 +255,8 @@ class TeslemetryChargePortEntity( self.async_write_ha_state() -class TeslemetryPollingChargePortEntity( - TeslemetryVehicleEntity, TeslemetryChargePortEntity +class TeslemetryVehiclePollingChargePortEntity( + TeslemetryVehiclePollingEntity, TeslemetryChargePortEntity ): """Polling cover entity for the charge port.""" @@ -298,6 +315,7 @@ class TeslemetryStreamingChargePortEntity( class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): """Base class for the front trunk cover entities.""" + api: Vehicle _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN @@ -312,8 +330,8 @@ class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): # In the future this could be extended to add aftermarket close support through a option flow -class TeslemetryPollingFrontTrunkEntity( - TeslemetryVehicleEntity, TeslemetryFrontTrunkEntity +class TeslemetryVehiclePollingFrontTrunkEntity( + TeslemetryVehiclePollingEntity, TeslemetryFrontTrunkEntity ): """Polling cover entity for the front trunk.""" @@ -359,6 +377,7 @@ class TeslemetryStreamingFrontTrunkEntity( class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity): """Cover entity for the rear trunk.""" + api: Vehicle _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -381,8 +400,8 @@ class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity): self.async_write_ha_state() -class TeslemetryPollingRearTrunkEntity( - TeslemetryVehicleEntity, TeslemetryRearTrunkEntity +class TeslemetryVehiclePollingRearTrunkEntity( + TeslemetryVehiclePollingEntity, TeslemetryRearTrunkEntity ): """Base class for the rear trunk cover entities.""" @@ -422,11 +441,13 @@ class TeslemetryStreamingRearTrunkEntity( """Update the entity attributes.""" self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() -class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): +class TeslemetrySunroofEntity(TeslemetryVehiclePollingEntity, CoverEntity): """Cover entity for the sunroof.""" + api: Vehicle _attr_device_class = CoverDeviceClass.WINDOW _attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index bb90a7b19bd..eb2c220ebbd 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity, TeslemetryVehicleStreamEntity +from .entity import TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity from .models import TeslemetryVehicleData PARALLEL_UPDATES = 0 @@ -47,19 +47,25 @@ DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = ( TeslemetryDeviceTrackerEntityDescription( key="location", polling_prefix="drive_state", - value_listener=lambda x, y: x.listen_Location(y), + value_listener=lambda vehicle, callback: vehicle.listen_Location(callback), streaming_firmware="2024.26", ), TeslemetryDeviceTrackerEntityDescription( key="route", polling_prefix="drive_state_active_route", - value_listener=lambda x, y: x.listen_DestinationLocation(y), - name_listener=lambda x, y: x.listen_DestinationName(y), + value_listener=lambda vehicle, callback: vehicle.listen_DestinationLocation( + callback + ), + name_listener=lambda vehicle, callback: vehicle.listen_DestinationName( + callback + ), streaming_firmware="2024.26", ), TeslemetryDeviceTrackerEntityDescription( key="origin", - value_listener=lambda x, y: x.listen_OriginLocation(y), + value_listener=lambda vehicle, callback: vehicle.listen_OriginLocation( + callback + ), streaming_firmware="2024.26", entity_registry_enabled_default=False, ), @@ -74,7 +80,8 @@ async def async_setup_entry( """Set up the Teslemetry device tracker platform from a config entry.""" entities: list[ - TeslemetryPollingDeviceTrackerEntity | TeslemetryStreamingDeviceTrackerEntity + TeslemetryVehiclePollingDeviceTrackerEntity + | TeslemetryStreamingDeviceTrackerEntity ] = [] # Only add vehicle location entities if the user has granted vehicle location scope. if Scope.VEHICLE_LOCATION not in entry.runtime_data.scopes: @@ -85,7 +92,9 @@ async def async_setup_entry( if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: if description.polling_prefix: entities.append( - TeslemetryPollingDeviceTrackerEntity(vehicle, description) + TeslemetryVehiclePollingDeviceTrackerEntity( + vehicle, description + ) ) else: entities.append( @@ -95,7 +104,9 @@ async def async_setup_entry( async_add_entities(entities) -class TeslemetryPollingDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): +class TeslemetryVehiclePollingDeviceTrackerEntity( + TeslemetryVehiclePollingEntity, TrackerEntity +): """Base class for Teslemetry Tracker Entities.""" entity_description: TeslemetryDeviceTrackerEntityDescription @@ -147,7 +158,6 @@ class TeslemetryStreamingDeviceTrackerEntity( """Handle entity which will be added.""" await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: - self._attr_state = state.state self._attr_latitude = state.attributes.get("latitude") self._attr_longitude = state.attributes.get("longitude") self._attr_location_name = state.attributes.get("location_name") @@ -165,12 +175,8 @@ class TeslemetryStreamingDeviceTrackerEntity( def _location_callback(self, location: TeslaLocation | None) -> None: """Update the value of the entity.""" - if location is None: - self._attr_available = False - else: - self._attr_available = True - self._attr_latitude = location.latitude - self._attr_longitude = location.longitude + self._attr_latitude = None if location is None else location.latitude + self._attr_longitude = None if location is None else location.longitude self.async_write_ha_state() def _name_callback(self, name: str | None) -> None: diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 4bc63fea5e2..762678736a5 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -27,7 +27,6 @@ class TeslemetryRootEntity(Entity): _attr_has_entity_name = True scoped: bool - api: Vehicle | EnergySite def raise_for_scope(self, scope: Scope): """Raise an error if a scope is not available.""" @@ -39,7 +38,7 @@ class TeslemetryRootEntity(Entity): ) -class TeslemetryEntity( +class TeslemetryPollingEntity( TeslemetryRootEntity, CoordinatorEntity[ TeslemetryVehicleDataCoordinator @@ -99,7 +98,7 @@ class TeslemetryEntity( """Update the attributes of the entity.""" -class TeslemetryVehicleEntity(TeslemetryEntity): +class TeslemetryVehiclePollingEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Vehicle entities.""" _last_update: int = 0 @@ -131,7 +130,7 @@ class TeslemetryVehicleEntity(TeslemetryEntity): return self.coordinator.data.get(self.key) -class TeslemetryEnergyLiveEntity(TeslemetryEntity): +class TeslemetryEnergyLiveEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Energy Site Live entities.""" api: EnergySite @@ -152,7 +151,7 @@ class TeslemetryEnergyLiveEntity(TeslemetryEntity): super().__init__(data.live_coordinator, key) -class TeslemetryEnergyInfoEntity(TeslemetryEntity): +class TeslemetryEnergyInfoEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Energy Site Info Entities.""" api: EnergySite @@ -171,7 +170,7 @@ class TeslemetryEnergyInfoEntity(TeslemetryEntity): super().__init__(data.info_coordinator, key) -class TeslemetryEnergyHistoryEntity(TeslemetryEntity): +class TeslemetryEnergyHistoryEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Energy History Entities.""" def __init__( @@ -190,7 +189,7 @@ class TeslemetryEnergyHistoryEntity(TeslemetryEntity): super().__init__(data.history_coordinator, key) -class TeslemetryWallConnectorEntity(TeslemetryEntity): +class TeslemetryWallConnectorEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Wall Connector Entities.""" _attr_has_entity_name = True @@ -249,6 +248,8 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): """Parent class for Teslemetry Vehicle Stream entities.""" + api: Vehicle + def __init__(self, data: TeslemetryVehicleData, key: str) -> None: """Initialize common aspects of a Teslemetry entity.""" self.vehicle = data @@ -261,8 +262,3 @@ class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): self._attr_translation_key = key self._attr_unique_id = f"{data.vin}-{key}" self._attr_device_info = data.device - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.stream.connected diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 06ac1595a80..edd5d404499 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -73,6 +73,12 @@ "on": "mdi:snowflake-melt" } }, + "lights_hazards_active": { + "state": { + "off": "mdi:car-light-dimmed", + "on": "mdi:hazard-lights" + } + }, "lights_high_beams": { "state": { "off": "mdi:car-light-dimmed", @@ -409,6 +415,9 @@ "brick_voltage_min": { "default": "mdi:battery-low" }, + "credit_balance": { + "default": "mdi:credit-card" + }, "cruise_follow_distance": { "default": "mdi:car-cruise-control" }, @@ -577,6 +586,12 @@ "hvac_fan_status": { "default": "mdi:fan" }, + "hvac_left_temperature_request": { + "default": "mdi:thermometer" + }, + "hvac_right_temperature_request": { + "default": "mdi:thermometer" + }, "isolation_resistance": { "default": "mdi:resistor" }, diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 68505a12a13..fda52357f5c 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -6,6 +6,7 @@ from itertools import chain from typing import Any from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant @@ -17,7 +18,7 @@ from . import TeslemetryConfigEntry from .const import DOMAIN from .entity import ( TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_vehicle_command @@ -38,7 +39,7 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingVehicleLockEntity( + TeslemetryVehiclePollingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) if vehicle.api.pre2021 or vehicle.firmware < "2024.26" @@ -48,7 +49,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryPollingCableLockEntity( + TeslemetryVehiclePollingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) if vehicle.api.pre2021 or vehicle.firmware < "2024.26" @@ -64,6 +65,8 @@ async def async_setup_entry( class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity): """Base vehicle lock entity for Teslemetry.""" + api: Vehicle + async def async_lock(self, **kwargs: Any) -> None: """Lock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) @@ -81,8 +84,8 @@ class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity): self.async_write_ha_state() -class TeslemetryPollingVehicleLockEntity( - TeslemetryVehicleEntity, TeslemetryVehicleLockEntity +class TeslemetryVehiclePollingVehicleLockEntity( + TeslemetryVehiclePollingEntity, TeslemetryVehicleLockEntity ): """Polling vehicle lock entity for Teslemetry.""" @@ -135,6 +138,8 @@ class TeslemetryStreamingVehicleLockEntity( class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): """Base cable Lock entity for Teslemetry.""" + api: Vehicle + async def async_lock(self, **kwargs: Any) -> None: """Charge cable Lock cannot be manually locked.""" raise ServiceValidationError( @@ -152,8 +157,8 @@ class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): self.async_write_ha_state() -class TeslemetryPollingCableLockEntity( - TeslemetryVehicleEntity, TeslemetryCableLockEntity +class TeslemetryVehiclePollingCableLockEntity( + TeslemetryVehiclePollingEntity, TeslemetryCableLockEntity ): """Polling cable lock entity for Teslemetry.""" diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 5b7454b87b6..7fc621eeeae 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.7.7"] + "requirements": ["tesla-fleet-api==1.1.1", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 50f15618e66..bf1fffed583 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -18,7 +18,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .entity import ( TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_vehicle_command @@ -52,7 +52,7 @@ async def async_setup_entry( """Set up the Teslemetry Media platform from a config entry.""" async_add_entities( - TeslemetryPollingMediaEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingMediaEntity(vehicle, entry.runtime_data.scopes) if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6" else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles @@ -63,7 +63,6 @@ class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity): """Base vehicle media player class.""" api: Vehicle - _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_volume_step = VOLUME_STEP @@ -107,7 +106,9 @@ class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity): await handle_vehicle_command(self.api.media_prev_track()) -class TeslemetryPollingMediaEntity(TeslemetryVehicleEntity, TeslemetryMediaEntity): +class TeslemetryVehiclePollingMediaEntity( + TeslemetryVehiclePollingEntity, TeslemetryMediaEntity +): """Polling vehicle media player class.""" def __init__( diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 4f0d26a1cba..51eed97227e 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -28,6 +28,7 @@ class TeslemetryData: vehicles: list[TeslemetryVehicleData] energysites: list[TeslemetryEnergyData] scopes: list[Scope] + stream: TeslemetryStream @dataclass diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 117c0a8c233..bb9f5b588a0 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -33,7 +33,7 @@ from . import TeslemetryConfigEntry from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_command, handle_vehicle_command @@ -140,7 +140,7 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingNumberEntity( + TeslemetryVehiclePollingNumberEntity( vehicle, description, entry.runtime_data.scopes, @@ -172,6 +172,7 @@ async def async_setup_entry( class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity): """Vehicle number entity base class.""" + api: Vehicle entity_description: TeslemetryNumberVehicleEntityDescription async def async_set_native_value(self, value: float) -> None: @@ -183,8 +184,8 @@ class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity): self.async_write_ha_state() -class TeslemetryPollingNumberEntity( - TeslemetryVehicleEntity, TeslemetryVehicleNumberEntity +class TeslemetryVehiclePollingNumberEntity( + TeslemetryVehiclePollingEntity, TeslemetryVehicleNumberEntity ): """Vehicle polling number entity.""" diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 9e13d15edc4..c24c47feb2e 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -20,7 +20,7 @@ from . import TeslemetryConfigEntry from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_command, handle_vehicle_command @@ -177,7 +177,7 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingSelectEntity( + TeslemetryVehiclePollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) if vehicle.api.pre2021 @@ -208,6 +208,7 @@ async def async_setup_entry( class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): """Parent vehicle select entity class.""" + api: Vehicle entity_description: TeslemetrySelectEntityDescription _climate: bool = False @@ -223,7 +224,9 @@ class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): self.async_write_ha_state() -class TeslemetryPollingSelectEntity(TeslemetryVehicleEntity, TeslemetrySelectEntity): +class TeslemetryVehiclePollingSelectEntity( + TeslemetryVehiclePollingEntity, TeslemetrySelectEntity +): """Base polling vehicle select entity class.""" def __init__( diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 3567069011d..8ddd7e186cb 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from teslemetry_stream import TeslemetryStreamVehicle +from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle from homeassistant.components.sensor import ( RestoreSensor, @@ -41,11 +41,11 @@ from .entity import ( TeslemetryEnergyHistoryEntity, TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, TeslemetryWallConnectorEntity, ) -from .models import TeslemetryEnergyData, TeslemetryVehicleData +from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 @@ -205,7 +205,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( key="charge_state_charging_state", polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( - lambda value: None if value is None else callback(value.lower()) + lambda value: callback(None if value is None else CHARGE_STATES.get(value)) ), polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), options=list(CHARGE_STATES.values()), @@ -477,6 +477,28 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_left_temperature_request", + streaming_listener=lambda vehicle, + callback: vehicle.listen_HvacLeftTemperatureRequest(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_right_temperature_request", + streaming_listener=lambda vehicle, + callback: vehicle.listen_HvacRightTemperatureRequest(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_traffic_minutes_delay", polling=True, @@ -511,7 +533,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( 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)) + lambda value: callback(None if value is None else BMS_STATES.get(value)) ), device_class=SensorDeviceClass.ENUM, options=list(BMS_STATES.values()), @@ -1596,6 +1618,12 @@ async def async_setup_entry( if energysite.history_coordinator is not None ) + entities.append( + TeslemetryCreditBalanceSensor( + entry.unique_id or entry.entry_id, entry.runtime_data + ) + ) + async_add_entities(entities) @@ -1633,7 +1661,7 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) self.async_write_ha_state() -class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): +class TeslemetryVehicleSensorEntity(TeslemetryVehiclePollingEntity, SensorEntity): """Base class for Teslemetry vehicle metric sensors.""" entity_description: TeslemetryVehicleSensorEntityDescription @@ -1696,7 +1724,7 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti self.async_write_ha_state() -class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): +class TeslemetryVehicleTimeSensorEntity(TeslemetryVehiclePollingEntity, SensorEntity): """Base class for Teslemetry vehicle time sensors.""" entity_description: TeslemetryTimeEntityDescription @@ -1803,3 +1831,33 @@ class TeslemetryEnergyHistorySensorEntity(TeslemetryEnergyHistoryEntity, SensorE def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" self._attr_native_value = self._value + + +class TeslemetryCreditBalanceSensor(RestoreSensor): + """Entity for Teslemetry Credit balance.""" + + _attr_has_entity_name = True + stream: TeslemetryStream + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_suggested_display_precision = 0 + + def __init__(self, uid: str, data: TeslemetryData) -> None: + """Initialize common aspects of a Teslemetry entity.""" + + self._attr_translation_key = "credit_balance" + self._attr_unique_id = f"{uid}_credit_balance" + self.stream = data.stream + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + if (sensor_data := await self.async_get_last_sensor_data()) is not None: + self._attr_native_value = sensor_data.native_value + + self.async_on_remove(self.stream.listen_Balance(self._async_update)) + + def _async_update(self, value: int) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 54568c971c4..57b6053bb48 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1,4 +1,10 @@ { + "common": { + "unavailable": "Unavailable", + "abort": "Abort", + "vehicle": "Vehicle", + "descr_pin": "4-digit code to enable or disable the setting" + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", @@ -203,6 +209,9 @@ "defrost_for_preconditioning": { "name": "Defrost for preconditioning" }, + "lights_hazards_active": { + "name": "Hazard lights" + }, "lights_high_beams": { "name": "High beams" }, @@ -482,6 +491,10 @@ "climate_state_passenger_temp_setting": { "name": "Passenger temperature setting" }, + "credit_balance": { + "name": "Teslemetry credits", + "unit_of_measurement": "credits" + }, "drive_state_active_route_destination": { "name": "Destination" }, @@ -565,7 +578,7 @@ "name": "Version" }, "vin": { - "name": "Vehicle", + "name": "[%key:component::teslemetry::common::vehicle%]", "state": { "disconnected": "[%key:common::state::disconnected%]" } @@ -750,40 +763,40 @@ "di_state_f": { "name": "Front drive inverter", "state": { - "unavailable": "Unavailable", + "unavailable": "[%key:component::teslemetry::common::unavailable%]", "standby": "[%key:common::state::standby%]", "fault": "[%key:common::state::fault%]", - "abort": "Abort", + "abort": "[%key:component::teslemetry::common::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%]", + "unavailable": "[%key:component::teslemetry::common::unavailable%]", "standby": "[%key:common::state::standby%]", "fault": "[%key:common::state::fault%]", - "abort": "[%key:component::teslemetry::entity::sensor::di_state_f::state::abort%]", + "abort": "[%key:component::teslemetry::common::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%]", + "unavailable": "[%key:component::teslemetry::common::unavailable%]", "standby": "[%key:common::state::standby%]", "fault": "[%key:common::state::fault%]", - "abort": "[%key:component::teslemetry::entity::sensor::di_state_f::state::abort%]", + "abort": "[%key:component::teslemetry::common::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%]", + "unavailable": "[%key:component::teslemetry::common::unavailable%]", "standby": "[%key:common::state::standby%]", "fault": "[%key:common::state::fault%]", - "abort": "[%key:component::teslemetry::entity::sensor::di_state_f::state::abort%]", + "abort": "[%key:component::teslemetry::common::abort%]", "enabled": "[%key:common::state::enabled%]" } }, @@ -1008,6 +1021,12 @@ "charge_rate_mile_per_hour": { "name": "Charge rate" }, + "hvac_left_temperature_request": { + "name": "Left temperature request" + }, + "hvac_right_temperature_request": { + "name": "Right temperature request" + }, "hvac_power_state": { "name": "HVAC power state", "state": { @@ -1109,7 +1128,7 @@ "fields": { "device_id": { "description": "Vehicle to share to.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "gps": { "description": "Location to navigate to.", @@ -1127,7 +1146,7 @@ "fields": { "device_id": { "description": "Vehicle to schedule.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { "description": "Enable or disable scheduled charging.", @@ -1149,7 +1168,7 @@ }, "device_id": { "description": "Vehicle to schedule.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { "description": "Enable or disable scheduled departure.", @@ -1183,15 +1202,15 @@ "fields": { "device_id": { "description": "Vehicle to limit.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { "description": "Enable or disable speed limit.", "name": "[%key:common::action::enable%]" }, "pin": { - "description": "4 digit PIN.", - "name": "PIN" + "description": "[%key:component::teslemetry::common::descr_pin%]", + "name": "[%key:common::config_flow::data::pin%]" } }, "name": "Set speed limit" @@ -1215,15 +1234,15 @@ "fields": { "device_id": { "description": "Vehicle to limit.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { "description": "Enable or disable valet mode.", "name": "[%key:common::action::enable%]" }, "pin": { - "description": "4 digit PIN.", - "name": "PIN" + "description": "[%key:component::teslemetry::common::descr_pin%]", + "name": "[%key:common::config_flow::data::pin%]" } }, "name": "Set valet mode" diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 645a8398820..f607429be46 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain from typing import Any from tesla_fleet_api.const import AutoSeat, Scope +from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.switch import ( @@ -24,7 +25,7 @@ from . import TeslemetryConfigEntry from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_command, handle_vehicle_command @@ -37,15 +38,14 @@ PARALLEL_UPDATES = 0 class TeslemetrySwitchEntityDescription(SwitchEntityDescription): """Describes Teslemetry Switch entity.""" - on_func: Callable - off_func: Callable + on_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] + off_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] scopes: list[Scope] value_func: Callable[[StateType], bool] = bool streaming_listener: Callable[ - [TeslemetryStreamVehicle, Callable[[StateType], None]], + [TeslemetryStreamVehicle, Callable[[bool | None], None]], Callable[[], None], ] - streaming_value_fn: Callable[[StateType], bool] = bool streaming_firmware: str = "2024.26" unique_id: str | None = None @@ -53,15 +53,28 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="vehicle_state_sentry_mode", - streaming_listener=lambda x, y: x.listen_SentryMode(y), - streaming_value_fn=lambda x: x != "Off", + streaming_listener=lambda vehicle, callback: vehicle.listen_SentryMode( + lambda value: callback(None if value is None else value != "Off") + ), on_func=lambda api: api.set_sentry_mode(on=True), off_func=lambda api: api.set_sentry_mode(on=False), scopes=[Scope.VEHICLE_CMDS], ), + TeslemetrySwitchEntityDescription( + key="vehicle_state_valet_mode", + streaming_listener=lambda vehicle, value: vehicle.listen_ValetModeEnabled( + value + ), + streaming_firmware="2024.44.25", + on_func=lambda api: api.set_valet_mode(on=True, password=""), + off_func=lambda api: api.set_valet_mode(on=False, password=""), + scopes=[Scope.VEHICLE_CMDS], + ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", - streaming_listener=lambda x, y: x.listen_AutoSeatClimateLeft(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_AutoSeatClimateLeft( + callback + ), on_func=lambda api: api.remote_auto_seat_climate_request( AutoSeat.FRONT_LEFT, True ), @@ -72,7 +85,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", - streaming_listener=lambda x, y: x.listen_AutoSeatClimateRight(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_AutoSeatClimateRight(callback), on_func=lambda api: api.remote_auto_seat_climate_request( AutoSeat.FRONT_RIGHT, True ), @@ -83,7 +97,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_auto_steering_wheel_heat", - streaming_listener=lambda x, y: x.listen_HvacSteeringWheelHeatAuto(y), + streaming_listener=lambda vehicle, + callback: vehicle.listen_HvacSteeringWheelHeatAuto(callback), on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( on=True ), @@ -94,8 +109,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_defrost_mode", - streaming_listener=lambda x, y: x.listen_DefrostMode(y), - streaming_value_fn=lambda x: x != "Off", + streaming_listener=lambda vehicle, callback: vehicle.listen_DefrostMode( + lambda value: callback(None if value is None else value != "Off") + ), on_func=lambda api: api.set_preconditioning_max(on=True, manual_override=False), off_func=lambda api: api.set_preconditioning_max( on=False, manual_override=False @@ -106,8 +122,11 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( key="charge_state_charging_state", unique_id="charge_state_user_charge_enable_request", value_func=lambda state: state in {"Starting", "Charging"}, - streaming_listener=lambda x, y: x.listen_DetailedChargeState(y), - streaming_value_fn=lambda x: x in {"Starting", "Charging"}, + streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( + lambda value: callback( + None if value is None else value in {"Starting", "Charging"} + ) + ), on_func=lambda api: api.charge_start(), off_func=lambda api: api.charge_stop(), scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], @@ -125,7 +144,7 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingVehicleSwitchEntity( + TeslemetryVehiclePollingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) if vehicle.api.pre2021 @@ -157,6 +176,7 @@ async def async_setup_entry( class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity): """Base class for all Teslemetry switch entities.""" + api: Vehicle _attr_device_class = SwitchDeviceClass.SWITCH entity_description: TeslemetrySwitchEntityDescription @@ -175,8 +195,8 @@ class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity): self.async_write_ha_state() -class TeslemetryPollingVehicleSwitchEntity( - TeslemetryVehicleEntity, TeslemetryVehicleSwitchEntity +class TeslemetryVehiclePollingVehicleSwitchEntity( + TeslemetryVehiclePollingEntity, TeslemetryVehicleSwitchEntity ): """Base class for Teslemetry polling vehicle switch entities.""" @@ -239,11 +259,9 @@ class TeslemetryStreamingVehicleSwitchEntity( ) ) - def _value_callback(self, value: StateType) -> None: + def _value_callback(self, value: bool | None) -> None: """Update the value of the entity.""" - self._attr_is_on = ( - None if value is None else self.entity_description.streaming_value_fn(value) - ) + self._attr_is_on = value self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index b8d40877de4..144a97039fc 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -15,7 +15,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .entity import ( TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_vehicle_command @@ -38,7 +38,7 @@ async def async_setup_entry( """Set up the Teslemetry update platform from a config entry.""" async_add_entities( - TeslemetryPollingUpdateEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingUpdateEntity(vehicle, entry.runtime_data.scopes) if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles @@ -62,7 +62,9 @@ class TeslemetryUpdateEntity(TeslemetryRootEntity, UpdateEntity): self.async_write_ha_state() -class TeslemetryPollingUpdateEntity(TeslemetryVehicleEntity, TeslemetryUpdateEntity): +class TeslemetryVehiclePollingUpdateEntity( + TeslemetryVehiclePollingEntity, TeslemetryUpdateEntity +): """Teslemetry Updates entity.""" def __init__( diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 3f71bcb95e3..9ad87e9dbbe 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.17"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.1.1"] } diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py index 139ee07ca5b..ecac11587c1 100644 --- a/homeassistant/components/tessie/media_player.py +++ b/homeassistant/components/tessie/media_player.py @@ -20,6 +20,10 @@ STATES = { "Stopped": MediaPlayerState.IDLE, } +# Tesla uses 31 steps, in 0.333 increments up to 10.333 +VOLUME_STEP = 1 / 31 +VOLUME_FACTOR = 31 / 3 # 10.333 + PARALLEL_UPDATES = 0 @@ -38,6 +42,7 @@ class TessieMediaEntity(TessieEntity, MediaPlayerEntity): """Vehicle Location Media Class.""" _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_volume_step = VOLUME_STEP def __init__( self, @@ -57,9 +62,7 @@ class TessieMediaEntity(TessieEntity, MediaPlayerEntity): @property def volume_level(self) -> float: """Volume level of the media player (0..1).""" - return self.get("vehicle_state_media_info_audio_volume", 0) / self.get( - "vehicle_state_media_info_audio_volume_max", 10.333333 - ) + return self.get("vehicle_state_media_info_audio_volume", 0) / VOLUME_FACTOR @property def media_duration(self) -> int | None: diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py index 471372a68bd..ce907deb9c8 100644 --- a/homeassistant/components/tessie/select.py +++ b/homeassistant/components/tessie/select.py @@ -168,6 +168,8 @@ class TessieExportRuleSelectEntity(TessieEnergyEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await handle_command(self.api.grid_import_export(option)) + await handle_command( + self.api.grid_import_export(customer_preferred_export_rule=option) + ) self._attr_current_option = option self.async_write_ha_state() diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index 9571597abe6..bab05bfc25e 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -36,9 +36,6 @@ PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.st _LOGGER = logging.getLogger(__name__) -MIN_TEMP = 61 -MAX_TEMP = 88 - HVAC_MAP = { HVACMode.HEAT: "heat", HVACMode.AUTO: "selfFeel", @@ -50,9 +47,6 @@ HVAC_MAP = { HVAC_MAP_REV = {v: k for k, v in HVAC_MAP.items()} -SUPPORT_FAN = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] -SUPPORT_SWING = [SWING_OFF, SWING_HORIZONTAL, SWING_VERTICAL, SWING_BOTH] - CURR_TEMP = "current_temp" TARGET_TEMP = "target_temp" OPERATION_MODE = "operation" @@ -74,7 +68,7 @@ async def async_setup_platform( except futures.TimeoutError: _LOGGER.error("Unable to connect to %s", config[CONF_HOST]) return - async_add_entities([TfiacClimate(hass, tfiac_client)]) + async_add_entities([TfiacClimate(tfiac_client)]) class TfiacClimate(ClimateEntity): @@ -88,34 +82,23 @@ class TfiacClimate(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_temp = 61 + _attr_max_temp = 88 + _attr_fan_modes = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] + _attr_hvac_modes = list(HVAC_MAP) + _attr_swing_modes = [SWING_OFF, SWING_HORIZONTAL, SWING_VERTICAL, SWING_BOTH] - def __init__(self, hass, client): + def __init__(self, client: Tfiac) -> None: """Init class.""" self._client = client - self._available = True - - @property - def available(self): - """Return if the device is available.""" - return self._available async def async_update(self) -> None: """Update status via socket polling.""" try: await self._client.update() - self._available = True + self._attr_available = True except futures.TimeoutError: - self._available = False - - @property - def min_temp(self): - """Return the minimum temperature.""" - return MIN_TEMP - - @property - def max_temp(self): - """Return the maximum temperature.""" - return MAX_TEMP + self._attr_available = False @property def name(self): @@ -145,33 +128,15 @@ class TfiacClimate(ClimateEntity): return HVAC_MAP_REV.get(state) @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return list(HVAC_MAP) - - @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the fan setting.""" return self._client.status["fan_mode"].lower() @property - def fan_modes(self): - """Return the list of available fan modes.""" - return SUPPORT_FAN - - @property - def swing_mode(self): + def swing_mode(self) -> str: """Return the swing setting.""" return self._client.status["swing_mode"].lower() - @property - def swing_modes(self): - """List of available swing modes.""" - return SUPPORT_SWING - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index ea8b469fd32..9460a50db80 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -4,8 +4,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -17,6 +19,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_ENTITY_ID], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_ENTITY_ID] + ), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups( entry, (Platform.BINARY_SENSOR,) ) diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index e565fdc7dd8..8335cc2d773 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN as TIBBER_DOMAIN +from .const import DOMAIN FIVE_YEARS = 5 * 365 * 24 @@ -80,7 +80,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): for sensor_type, is_production, unit in sensors: statistic_id = ( - f"{TIBBER_DOMAIN}:energy_" + f"{DOMAIN}:energy_" f"{sensor_type.lower()}_" f"{home.home_id.replace('-', '')}" ) @@ -166,7 +166,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{home.name} {sensor_type}", - source=TIBBER_DOMAIN, + source=DOMAIN, statistic_id=statistic_id, unit_of_measurement=unit, ) diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index df6541591e0..5a10d8e0890 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as TIBBER_DOMAIN +from . import DOMAIN async def async_setup_entry( @@ -30,7 +30,7 @@ class TibberNotificationEntity(NotifyEntity): """Implement the notification entity service for Tibber.""" _attr_supported_features = NotifyEntityFeature.TITLE - _attr_name = TIBBER_DOMAIN + _attr_name = DOMAIN _attr_icon = "mdi:message-flash" def __init__(self, unique_id: str) -> None: @@ -39,12 +39,12 @@ class TibberNotificationEntity(NotifyEntity): async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message to Tibber devices.""" - tibber_connection: Tibber = self.hass.data[TIBBER_DOMAIN] + tibber_connection: Tibber = self.hass.data[DOMAIN] try: await tibber_connection.send_notification( title or ATTR_TITLE_DEFAULT, message ) except TimeoutError as exc: raise HomeAssistantError( - translation_domain=TIBBER_DOMAIN, translation_key="send_message_timeout" + translation_domain=DOMAIN, translation_key="send_message_timeout" ) from exc diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 9f87b8a8490..26b8f5400a0 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -41,7 +41,7 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import Throttle, dt as dt_util -from .const import DOMAIN as TIBBER_DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER from .coordinator import TibberDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -267,7 +267,7 @@ async def async_setup_entry( ) -> None: """Set up the Tibber sensor.""" - tibber_connection = hass.data[TIBBER_DOMAIN] + tibber_connection = hass.data[DOMAIN] entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -309,21 +309,17 @@ async def async_setup_entry( continue # migrate to new device ids - old_entity_id = entity_registry.async_get_entity_id( - "sensor", TIBBER_DOMAIN, old_id - ) + old_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, old_id) if old_entity_id is not None: entity_registry.async_update_entity( old_entity_id, new_unique_id=home.home_id ) # migrate to new device ids - device_entry = device_registry.async_get_device( - identifiers={(TIBBER_DOMAIN, old_id)} - ) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, old_id)}) if device_entry and entry.entry_id in device_entry.config_entries: device_registry.async_update_device( - device_entry.id, new_identifiers={(TIBBER_DOMAIN, home.home_id)} + device_entry.id, new_identifiers={(DOMAIN, home.home_id)} ) async_add_entities(entities, True) @@ -352,7 +348,7 @@ class TibberSensor(SensorEntity): def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" device_info = DeviceInfo( - identifiers={(TIBBER_DOMAIN, self._tibber_home.home_id)}, + identifiers={(DOMAIN, self._tibber_home.home_id)}, name=self._device_name, manufacturer=MANUFACTURER, ) @@ -553,19 +549,19 @@ class TibberRtEntityCreator: if translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION_SIMPLE: entity_id = self._entity_registry.async_get_entity_id( "sensor", - TIBBER_DOMAIN, + DOMAIN, f"{home_id}_rt_{translation_key.replace('_', ' ')}", ) elif translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION: entity_id = self._entity_registry.async_get_entity_id( "sensor", - TIBBER_DOMAIN, + DOMAIN, f"{home_id}_rt_{RT_SENSORS_UNIQUE_ID_MIGRATION[translation_key]}", ) elif translation_key != description_key: entity_id = self._entity_registry.async_get_entity_id( "sensor", - TIBBER_DOMAIN, + DOMAIN, f"{home_id}_rt_{translation_key}", ) diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index daf720084a5..f3174b72a8e 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Total Connect 2.0 Account Credentials", + "title": "Total Connect 2.0 account credentials", "description": "It is highly recommended to use a 'standard' Total Connect user account with Home Assistant. The account should not have full administrative privileges.", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -14,13 +14,13 @@ } }, "locations": { - "title": "Location Usercodes", + "title": "Location usercodes", "description": "Enter the usercode for this user at location {location_id}", "data": { "usercodes": "Usercode" }, "data_description": { - "usercodes": "The usercode is usually a 4 digit number" + "usercodes": "The usercode is usually a 4-digit number" } }, "reauth_confirm": { @@ -41,13 +41,13 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "no_locations": "No locations are available for this user, check TotalConnect settings" + "no_locations": "No locations are available for this user, check Total Connect settings" } }, "options": { "step": { "init": { - "title": "TotalConnect Options", + "title": "Total Connect options", "data": { "auto_bypass_low_battery": "Auto bypass low battery", "code_required": "Require user to enter code for alarm actions" @@ -62,11 +62,11 @@ "services": { "arm_away_instant": { "name": "Arm away instant", - "description": "Arms Away with zero entry delay." + "description": "Arms away with zero entry delay." }, "arm_home_instant": { "name": "Arm home instant", - "description": "Arms Home with zero entry delay." + "description": "Arms home with zero entry delay." } }, "entity": { diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index 86526f4718b..971c83c2b39 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -67,6 +67,7 @@ class Touchline(ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] + _attr_preset_modes = list(PRESET_MODES) _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) @@ -75,52 +76,25 @@ class Touchline(ClimateEntity): def __init__(self, touchline_thermostat): """Initialize the Touchline device.""" self.unit = touchline_thermostat - self._name = None - self._current_temperature = None - self._target_temperature = None + self._attr_name = None self._current_operation_mode = None - self._preset_mode = None + self._attr_preset_mode = None def update(self) -> None: """Update thermostat attributes.""" self.unit.update() - self._name = self.unit.get_name() - self._current_temperature = self.unit.get_current_temperature() - self._target_temperature = self.unit.get_target_temperature() - self._preset_mode = TOUCHLINE_HA_PRESETS.get( + self._attr_name = self.unit.get_name() + self._attr_current_temperature = self.unit.get_current_temperature() + self._attr_target_temperature = self.unit.get_target_temperature() + self._attr_preset_mode = TOUCHLINE_HA_PRESETS.get( (self.unit.get_operation_mode(), self.unit.get_week_program()) ) - @property - def name(self): - """Return the name of the climate device.""" - return self._name - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def preset_mode(self): - """Return the current preset mode.""" - return self._preset_mode - - @property - def preset_modes(self): - """Return available preset modes.""" - return list(PRESET_MODES) - - def set_preset_mode(self, preset_mode): + def set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" - preset_mode = PRESET_MODES[preset_mode] - self.unit.set_operation_mode(preset_mode.mode) - self.unit.set_week_program(preset_mode.program) + preset = PRESET_MODES[preset_mode] + self.unit.set_operation_mode(preset.mode) + self.unit.set_week_program(preset.program) def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -129,5 +103,5 @@ class Touchline(ClimateEntity): def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if kwargs.get(ATTR_TEMPERATURE) is not None: - self._target_temperature = kwargs.get(ATTR_TEMPERATURE) - self.unit.set_target_temperature(self._target_temperature) + self._attr_target_temperature = kwargs.get(ATTR_TEMPERATURE) + self.unit.set_target_temperature(self._attr_target_temperature) diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 66c46dd482e..8b86a6df9ab 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -17,7 +17,7 @@ "invalid_security_code": "Failed to register with provided code. If this keeps happening, try restarting the gateway.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout": "Timeout validating the code.", - "cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?" + "cannot_authenticate": "Cannot authenticate, is your gateway paired with another server like e.g. HomeKit?" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index c38730e7591..086ac818c8e 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -6,8 +6,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes PLATFORMS = [Platform.BINARY_SENSOR] @@ -21,6 +23,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_ENTITY_ID], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we remove the config entry because + # trend does not allow replacing the input entity. + await hass.config_entries.async_remove(entry.entry_id) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_ENTITY_ID] + ), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 4261f96bbe6..2bc5949b970 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -239,7 +239,14 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): self.async_schedule_update_ha_state(True) except (ValueError, TypeError) as ex: - _LOGGER.error(ex) + _LOGGER.error( + "Error processing sensor state change for " + "entity_id=%s, attribute=%s, state=%s: %s", + self._entity_id, + self._attribute, + new_state.state, + ex, + ) self.async_on_remove( async_track_state_change_event( diff --git a/homeassistant/components/triggercmd/switch.py b/homeassistant/components/triggercmd/switch.py index e04cf5ee7e8..e03ff333751 100644 --- a/homeassistant/components/triggercmd/switch.py +++ b/homeassistant/components/triggercmd/switch.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from triggercmd import client, ha @@ -59,13 +60,13 @@ class TRIGGERcmdSwitch(SwitchEntity): """Return True if hub is available.""" return self._switch.hub.online - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.trigger("on") self._attr_is_on = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.trigger("off") self._attr_is_on = False diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index b279af31803..8292df07ef8 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -25,6 +25,9 @@ import voluptuous as vol from homeassistant.components import ffmpeg, websocket_api from homeassistant.components.http import HomeAssistantView +from homeassistant.components.media_source import ( + generate_media_source_id as ms_generate_media_source_id, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT from homeassistant.core import ( @@ -58,6 +61,7 @@ from .const import ( DEFAULT_CACHE_DIR, DEFAULT_TIME_MEMORY, DOMAIN, + MEDIA_SOURCE_STREAM_PATH, TtsAudioType, ) from .entity import TextToSpeechEntity, TTSAudioRequest, TTSAudioResponse @@ -273,9 +277,17 @@ 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] 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"]) + if "stream" in parsed: + stream = manager.async_get_result_stream( + parsed["stream"] # type: ignore[typeddict-item] + ) + if stream is None: + raise ValueError("Stream not found") + else: + stream = 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 @@ -469,6 +481,7 @@ class ResultStream: use_file_cache: bool language: str options: dict + supports_streaming_input: bool _manager: SpeechManager @@ -477,6 +490,14 @@ class ResultStream: """Get the URL to stream the result.""" return f"/api/tts_proxy/{self.token}" + @cached_property + def media_source_id(self) -> str: + """Get the media source ID of this stream.""" + return ms_generate_media_source_id( + DOMAIN, + f"{MEDIA_SOURCE_STREAM_PATH}/{self.token}", + ) + @cached_property def _result_cache(self) -> asyncio.Future[TTSCache]: """Get the future that returns the cache.""" @@ -484,7 +505,10 @@ class ResultStream: @callback def async_set_message(self, message: str) -> None: - """Set message to be generated.""" + """Set message to be generated. + + This method will leverage a disk cache to speed up generation. + """ self._result_cache.set_result( self._manager.async_cache_message_in_memory( engine=self.engine, @@ -497,7 +521,10 @@ class ResultStream: @callback def async_set_message_stream(self, message_stream: AsyncGenerator[str]) -> None: - """Set a stream that will generate the message.""" + """Set a stream that will generate the message. + + This method can result in faster first byte when generating long responses. + """ self._result_cache.set_result( self._manager.async_cache_message_stream_in_memory( engine=self.engine, @@ -726,6 +753,10 @@ class SpeechManager: if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") + supports_streaming_input = ( + isinstance(engine_instance, TextToSpeechEntity) + and engine_instance.async_supports_streaming_input() + ) language, options = self.process_options(engine_instance, language, options) if use_file_cache is None: use_file_cache = self.use_file_cache @@ -741,6 +772,7 @@ class SpeechManager: engine=engine, language=language, options=options, + supports_streaming_input=supports_streaming_input, _manager=self, ) self.token_to_stream[token] = result_stream @@ -820,12 +852,9 @@ class SpeechManager: 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_stream(), language, options + engine_instance, message, language, options ) cache = TTSCache( @@ -899,7 +928,7 @@ class SpeechManager: async def _async_generate_tts_audio( self, engine_instance: TextToSpeechEntity | Provider, - message_stream: AsyncGenerator[str], + message_or_stream: str | AsyncGenerator[str], language: str, options: dict[str, Any], ) -> AsyncGenerator[bytes]: @@ -947,9 +976,12 @@ class SpeechManager: if engine_instance.name is None or engine_instance.name is UNDEFINED: 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( + if isinstance(engine_instance, Provider) or isinstance(message_or_stream, str): + if isinstance(message_or_stream, str): + message = message_or_stream + else: + message = "".join([chunk async for chunk in message_or_stream]) + extension, data = await engine_instance.async_internal_get_tts_audio( message, language, options ) @@ -965,7 +997,7 @@ class SpeechManager: else: tts_result = await engine_instance.internal_async_stream_tts_audio( - TTSAudioRequest(language, options, message_stream) + TTSAudioRequest(language, options, message_or_stream) ) extension = tts_result.extension data_gen = tts_result.data_gen diff --git a/homeassistant/components/tts/const.py b/homeassistant/components/tts/const.py index 42c7d710ad4..830e0053cee 100644 --- a/homeassistant/components/tts/const.py +++ b/homeassistant/components/tts/const.py @@ -30,4 +30,6 @@ DATA_COMPONENT: HassKey[EntityComponent[TextToSpeechEntity]] = HassKey(DOMAIN) DATA_TTS_MANAGER: HassKey[SpeechManager] = HassKey("tts_manager") +MEDIA_SOURCE_STREAM_PATH = "-stream-" + type TtsAudioType = tuple[str | None, bytes | None] diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index 199d673398e..2c3fd446d2f 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -89,6 +89,13 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH """Return a mapping with the default options.""" return self._attr_default_options + @classmethod + def async_supports_streaming_input(cls) -> bool: + """Return if the TTS engine supports streaming input.""" + return ( + cls.async_stream_tts_audio is not TextToSpeechEntity.async_stream_tts_audio + ) + @callback def async_get_supported_voices(self, language: str) -> list[Voice] | None: """Return a list of supported voices for a language.""" @@ -158,6 +165,18 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH self.async_write_ha_state() return await self.async_stream_tts_audio(request) + @final + async def async_internal_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine and update state. + + Return a tuple of file extension and data as bytes. + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_get_tts_audio(message, language, options=options) + async def async_stream_tts_audio( self, request: TTSAudioRequest ) -> TTSAudioResponse: diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 877ecc034d6..c3d7eb6fdd6 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -7,7 +7,7 @@ from collections.abc import Coroutine, Mapping from functools import partial import logging from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -252,3 +252,15 @@ class Provider: return await self.hass.async_add_executor_job( partial(self.get_tts_audio, message, language, options=options) ) + + @final + async def async_internal_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from provider. + + Proxies request to mimic the entity interface. + + Return a tuple of file extension and data as bytes. + """ + return await self.async_get_tts_audio(message, language, options) diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index f096e082364..91192fdca13 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -19,7 +19,7 @@ from homeassistant.components.media_source import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .const import DATA_COMPONENT, DATA_TTS_MANAGER, DOMAIN +from .const import DATA_COMPONENT, DATA_TTS_MANAGER, DOMAIN, MEDIA_SOURCE_STREAM_PATH from .helper import get_engine_instance URL_QUERY_TTS_OPTIONS = "tts_options" @@ -81,10 +81,22 @@ class ParsedMediaSourceId(TypedDict): message: str +class ParsedMediaSourceStreamId(TypedDict): + """Parsed media source ID for a stream.""" + + stream: str + + @callback -def parse_media_source_id(media_source_id: str) -> ParsedMediaSourceId: +def parse_media_source_id( + media_source_id: str, +) -> ParsedMediaSourceId | ParsedMediaSourceStreamId: """Turn a media source ID into options.""" parsed = URL(media_source_id) + + if parsed.path.startswith(f"{MEDIA_SOURCE_STREAM_PATH}/"): + return {"stream": parsed.path[len(MEDIA_SOURCE_STREAM_PATH) + 1 :]} + if URL_QUERY_TTS_OPTIONS in parsed.query: try: options = json.loads(parsed.query[URL_QUERY_TTS_OPTIONS]) @@ -122,17 +134,24 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" + manager = self.hass.data[DATA_TTS_MANAGER] try: parsed = parse_media_source_id(item.identifier) - stream = self.hass.data[DATA_TTS_MANAGER].async_create_result_stream( - **parsed["options"] - ) - stream.async_set_message(parsed["message"]) + if "stream" in parsed: + stream = manager.async_get_result_stream( + parsed["stream"], # type: ignore[typeddict-item] + ) + else: + stream = manager.async_create_result_stream(**parsed["options"]) + stream.async_set_message(parsed["message"]) except Unresolvable: raise except HomeAssistantError as err: raise Unresolvable(str(err)) from err + if stream is None: + raise Unresolvable("Stream not found") + return PlayMedia(stream.url, stream.content_type) async def async_browse_media( diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 96f7d3a1e1c..4972fe88339 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -2,6 +2,8 @@ from __future__ import annotations +from base64 import b64decode +from dataclasses import dataclass from enum import StrEnum from tuya_sharing import CustomerDevice, Manager @@ -18,7 +20,15 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import TuyaEntity +from .entity import EnumTypeData, TuyaEntity + + +@dataclass(frozen=True) +class TuyaAlarmControlPanelEntityDescription(AlarmControlPanelEntityDescription): + """Describe a Tuya Alarm Control Panel entity.""" + + master_state: DPCode | None = None + alarm_msg: DPCode | None = None class Mode(StrEnum): @@ -30,6 +40,13 @@ class Mode(StrEnum): SOS = "sos" +class State(StrEnum): + """Alarm states.""" + + NORMAL = "normal" + ALARM = "alarm" + + STATE_MAPPING: dict[str, AlarmControlPanelState] = { Mode.DISARMED: AlarmControlPanelState.DISARMED, Mode.ARM: AlarmControlPanelState.ARMED_AWAY, @@ -40,12 +57,14 @@ STATE_MAPPING: dict[str, AlarmControlPanelState] = { # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -ALARM: dict[str, tuple[AlarmControlPanelEntityDescription, ...]] = { +ALARM: dict[str, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = { # Alarm Host # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf "mal": ( - AlarmControlPanelEntityDescription( + TuyaAlarmControlPanelEntityDescription( key=DPCode.MASTER_MODE, + master_state=DPCode.MASTER_STATE, + alarm_msg=DPCode.ALARM_MSG, name="Alarm", ), ) @@ -86,12 +105,14 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): _attr_name = None _attr_code_arm_required = False + _master_state: EnumTypeData | None = None + _alarm_msg_dpcode: DPCode | None = None def __init__( self, device: CustomerDevice, device_manager: Manager, - description: AlarmControlPanelEntityDescription, + description: TuyaAlarmControlPanelEntityDescription, ) -> None: """Init Tuya Alarm.""" super().__init__(device, device_manager) @@ -111,13 +132,39 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): if Mode.SOS in supported_modes.range: self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER + # Determine master state + if enum_type := self.find_dpcode( + description.master_state, dptype=DPType.ENUM, prefer_function=True + ): + self._master_state = enum_type + + # Determine alarm message + if dp_code := self.find_dpcode(description.alarm_msg, prefer_function=True): + self._alarm_msg_dpcode = dp_code + @property def alarm_state(self) -> AlarmControlPanelState | None: """Return the state of the device.""" + # When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'. + # The 'mode' doesn't change, and stays as 'arm' or 'home'. + if self._master_state is not None: + if self.device.status.get(self._master_state.dpcode) == State.ALARM: + return AlarmControlPanelState.TRIGGERED + if not (status := self.device.status.get(self.entity_description.key)): return None return STATE_MAPPING.get(status) + @property + def changed_by(self) -> str | None: + """Last change triggered by.""" + if self._master_state is not None and self._alarm_msg_dpcode is not None: + if self.device.status.get(self._master_state.dpcode) == State.ALARM: + encoded_msg = self.device.status.get(self._alarm_msg_dpcode) + if encoded_msg: + return b64decode(encoded_msg).decode("utf-16be") + return None + def alarm_disarm(self, code: str | None = None) -> None: """Send Disarm command.""" self._send_command( diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index deccb08c5aa..547f3a14c93 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -293,7 +293,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ) self._send_command(commands) - def set_preset_mode(self, preset_mode): + def set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" commands = [{"code": DPCode.MODE, "value": preset_mode}] self._send_command(commands) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index a40260ed787..a40468fdc8f 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -56,6 +56,7 @@ PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, @@ -101,6 +102,7 @@ class DPCode(StrEnum): ALARM_TIME = "alarm_time" # Alarm time ALARM_VOLUME = "alarm_volume" # Alarm volume ALARM_MESSAGE = "alarm_message" + ALARM_MSG = "alarm_msg" ANGLE_HORIZONTAL = "angle_horizontal" ANGLE_VERTICAL = "angle_vertical" ANION = "anion" # Ionizer unit @@ -198,7 +200,8 @@ class DPCode(StrEnum): FEED_REPORT = "feed_report" FEED_STATE = "feed_state" FILTER = "filter" - FILTER_LIFE = "filter" + FILTER_DURATION = "filter_life" # Filter duration (hours) + FILTER_LIFE = "filter" # Filter life (percentage) FILTER_RESET = "filter_reset" # Filter (cartridge) reset FLOODLIGHT_LIGHTNESS = "floodlight_lightness" FLOODLIGHT_SWITCH = "floodlight_switch" @@ -217,11 +220,14 @@ class DPCode(StrEnum): LED_TYPE_2 = "led_type_2" LED_TYPE_3 = "led_type_3" LEVEL = "level" + LEVEL_1 = "level_1" + LEVEL_2 = "level_2" LEVEL_CURRENT = "level_current" LIGHT = "light" # Light LIGHT_MODE = "light_mode" LOCK = "lock" # Lock / Child lock MASTER_MODE = "master_mode" # alarm mode + MASTER_STATE = "master_state" # alarm state MACH_OPERATE = "mach_operate" MANUAL_FEED = "manual_feed" MATERIAL = "material" # Material @@ -255,12 +261,16 @@ class DPCode(StrEnum): POWDER_SET = "powder_set" # Powder POWER = "power" POWER_GO = "power_go" + PREHEAT = "preheat" + PREHEAT_1 = "preheat_1" + PREHEAT_2 = "preheat_2" POWER_TOTAL = "power_total" PRESENCE_STATE = "presence_state" PRESSURE_STATE = "pressure_state" PRESSURE_VALUE = "pressure_value" PUMP = "pump" PUMP_RESET = "pump_reset" # Water pump reset + PUMP_TIME = "pump_time" # Water pump duration OXYGEN = "oxygen" # Oxygen bar RECORD_MODE = "record_mode" RECORD_SWITCH = "record_switch" # Recording switch @@ -314,6 +324,15 @@ class DPCode(StrEnum): SWITCH_LED_1 = "switch_led_1" SWITCH_LED_2 = "switch_led_2" SWITCH_LED_3 = "switch_led_3" + SWITCH_MODE1 = "switch_mode1" + SWITCH_MODE2 = "switch_mode2" + SWITCH_MODE3 = "switch_mode3" + SWITCH_MODE4 = "switch_mode4" + SWITCH_MODE5 = "switch_mode5" + SWITCH_MODE6 = "switch_mode6" + SWITCH_MODE7 = "switch_mode7" + SWITCH_MODE8 = "switch_mode8" + SWITCH_MODE9 = "switch_mode9" SWITCH_NIGHT_LIGHT = "switch_night_light" SWITCH_SAVE_ENERGY = "switch_save_energy" SWITCH_SOUND = "switch_sound" # Voice switch @@ -359,6 +378,7 @@ class DPCode(StrEnum): UPPER_TEMP = "upper_temp" UPPER_TEMP_F = "upper_temp_f" UV = "uv" # UV sterilization + UV_RUNTIME = "uv_runtime" # UV runtime VA_BATTERY = "va_battery" VA_HUMIDITY = "va_humidity" VA_TEMPERATURE = "va_temperature" @@ -372,6 +392,7 @@ class DPCode(StrEnum): WATER = "water" WATER_RESET = "water_reset" # Resetting of water usage days WATER_SET = "water_set" # Water level + WATER_TIME = "water_time" # Water usage duration WATERSENSOR_STATE = "watersensor_state" WEATHER_DELAY = "weather_delay" WET = "wet" # Humidification diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py new file mode 100644 index 00000000000..09ab8e8f544 --- /dev/null +++ b/homeassistant/components/tuya/event.py @@ -0,0 +1,147 @@ +"""Support for Tuya event entities.""" + +from __future__ import annotations + +from tuya_sharing import CustomerDevice, Manager + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TuyaConfigEntry +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .entity import TuyaEntity + +# All descriptions can be found here. Mostly the Enum data types in the +# default status set of each category (that don't have a set instruction) +# end up being events. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +EVENTS: dict[str, tuple[EventEntityDescription, ...]] = { + # Wireless Switch + # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp + "wxkg": ( + EventEntityDescription( + key=DPCode.SWITCH_MODE1, + device_class=EventDeviceClass.BUTTON, + translation_key="numbered_button", + translation_placeholders={"button_number": "1"}, + ), + EventEntityDescription( + key=DPCode.SWITCH_MODE2, + device_class=EventDeviceClass.BUTTON, + translation_key="numbered_button", + translation_placeholders={"button_number": "2"}, + ), + EventEntityDescription( + key=DPCode.SWITCH_MODE3, + device_class=EventDeviceClass.BUTTON, + translation_key="numbered_button", + translation_placeholders={"button_number": "3"}, + ), + EventEntityDescription( + key=DPCode.SWITCH_MODE4, + device_class=EventDeviceClass.BUTTON, + translation_key="numbered_button", + translation_placeholders={"button_number": "4"}, + ), + EventEntityDescription( + key=DPCode.SWITCH_MODE5, + device_class=EventDeviceClass.BUTTON, + translation_key="numbered_button", + translation_placeholders={"button_number": "5"}, + ), + EventEntityDescription( + key=DPCode.SWITCH_MODE6, + device_class=EventDeviceClass.BUTTON, + translation_key="numbered_button", + translation_placeholders={"button_number": "6"}, + ), + EventEntityDescription( + key=DPCode.SWITCH_MODE7, + device_class=EventDeviceClass.BUTTON, + translation_key="numbered_button", + translation_placeholders={"button_number": "7"}, + ), + EventEntityDescription( + key=DPCode.SWITCH_MODE8, + device_class=EventDeviceClass.BUTTON, + translation_key="numbered_button", + translation_placeholders={"button_number": "8"}, + ), + EventEntityDescription( + key=DPCode.SWITCH_MODE9, + device_class=EventDeviceClass.BUTTON, + translation_key="numbered_button", + translation_placeholders={"button_number": "9"}, + ), + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Tuya events dynamically through Tuya discovery.""" + hass_data = entry.runtime_data + + @callback + def async_discover_device(device_ids: list[str]) -> None: + """Discover and add a discovered Tuya binary sensor.""" + entities: list[TuyaEventEntity] = [] + for device_id in device_ids: + device = hass_data.manager.device_map[device_id] + if descriptions := EVENTS.get(device.category): + for description in descriptions: + dpcode = description.key + if dpcode in device.status: + entities.append( + TuyaEventEntity(device, hass_data.manager, description) + ) + + async_add_entities(entities) + + async_discover_device([*hass_data.manager.device_map]) + + entry.async_on_unload( + async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) + ) + + +class TuyaEventEntity(TuyaEntity, EventEntity): + """Tuya Event Entity.""" + + entity_description: EventEntityDescription + + def __init__( + self, + device: CustomerDevice, + device_manager: Manager, + description: EventEntityDescription, + ) -> None: + """Init Tuya event entity.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + if dpcode := self.find_dpcode(description.key, dptype=DPType.ENUM): + self._attr_event_types: list[str] = dpcode.range + + async def _handle_state_update( + self, updated_status_properties: list[str] | None + ) -> None: + if ( + updated_status_properties is None + or self.entity_description.key not in updated_status_properties + ): + return + + value = self.device.status.get(self.entity_description.key) + self._trigger_event(value) + self.async_write_ha_state() diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 6c47148eeda..f8fd9237ffc 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any from tuya_sharing import CustomerDevice, Manager @@ -165,11 +166,11 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): return round(self._current_humidity.scale_value(current_humidity)) - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._send_command([{"code": self._switch_dpcode, "value": True}]) - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" self._send_command([{"code": self._switch_dpcode, "value": False}]) @@ -189,6 +190,6 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): ] ) - def set_mode(self, mode): + def set_mode(self, mode: str) -> None: """Set new target preset mode.""" self._send_command([{"code": DPCode.MODE, "value": mode}]) diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 553191b7d45..21f88156236 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -316,6 +316,28 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Electric Blanket + # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + "dr": ( + SelectEntityDescription( + key=DPCode.LEVEL, + name="Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + SelectEntityDescription( + key=DPCode.LEVEL_1, + name="Side A Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + SelectEntityDescription( + key=DPCode.LEVEL_2, + name="Side B Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + ), } # Socket (duplicate of `kg`) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 9e40bda5d4d..912632c074b 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -305,6 +305,36 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Pet Fountain + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r0as4ln + "cwysj": ( + TuyaSensorEntityDescription( + key=DPCode.UV_RUNTIME, + translation_key="uv_runtime", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.PUMP_TIME, + translation_key="pump_time", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.FILTER_DURATION, + translation_key="filter_duration", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WATER_TIME, + translation_key="water_time", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), # Air Quality Monitor # https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv "hjjcy": ( @@ -1281,6 +1311,9 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Wireless Switch + # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp + "wxkg": BATTERY_SENSORS, } # Socket (duplicate of `kg`) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index c6f6bfe9776..ff67ac19806 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -101,6 +101,20 @@ "name": "Door 3" } }, + "event": { + "numbered_button": { + "name": "Button {button_number}", + "state_attributes": { + "event_type": { + "state": { + "click": "Clicked", + "double_click": "Double-clicked", + "press": "Long-pressed" + } + } + } + } + }, "light": { "backlight": { "name": "Backlight" @@ -448,6 +462,20 @@ "144h": "144h", "168h": "168h" } + }, + "blanket_level": { + "state": { + "level_1": "Low", + "level_2": "Level 2", + "level_3": "Level 3", + "level_4": "Level 4", + "level_5": "Level 5", + "level_6": "Level 6", + "level_7": "Level 7", + "level_8": "Level 8", + "level_9": "Level 9", + "level_10": "High" + } } }, "sensor": { @@ -640,6 +668,18 @@ "level_5": "Level 5", "level_6": "Level 6" } + }, + "uv_runtime": { + "name": "UV runtime" + }, + "pump_time": { + "name": "Water pump duration" + }, + "filter_duration": { + "name": "Filter duration" + }, + "water_time": { + "name": "Water usage duration" } }, "switch": { diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 4000e8d9b24..a1d90c6ec2b 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -80,7 +80,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Pet Water Feeder + # Pet Fountain # https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 "cwysj": ( SwitchEntityDescription( @@ -729,6 +729,46 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), + # Electric Blanket + # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + "dr": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name="Power", + icon="mdi:power", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_1, + name="Side A Power", + icon="mdi:alpha-a", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + name="Side B Power", + icon="mdi:alpha-b", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT, + name="Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT_1, + name="Side A Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT_2, + name="Side B Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + ), } # Socket (duplicate of `pc`) diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index b893b612f2a..71404ef4bc2 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN as UNIFI_DOMAIN, PLATFORMS, UNIFI_WIRELESS_CLIENTS +from .const import DOMAIN, PLATFORMS, UNIFI_WIRELESS_CLIENTS from .errors import AuthenticationRequired, CannotConnect from .hub import UnifiHub, get_unifi_api from .services import async_setup_services @@ -22,7 +22,7 @@ SAVE_DELAY = 10 STORAGE_KEY = "unifi_data" STORAGE_VERSION = 1 -CONFIG_SCHEMA = cv.config_entry_only_config_schema(UNIFI_DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 3878e4c60eb..c8c6a54f9fe 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -56,7 +56,7 @@ from .const import ( CONF_TRACK_DEVICES, CONF_TRACK_WIRED_CLIENTS, DEFAULT_DPI_RESTRICTIONS, - DOMAIN as UNIFI_DOMAIN, + DOMAIN, ) from .errors import AuthenticationRequired, CannotConnect from .hub import UnifiHub, get_unifi_api @@ -72,7 +72,7 @@ MODEL_PORTS = { } -class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN): +class UnifiFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a UniFi Network config flow.""" VERSION = 1 diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index a26232664a8..1084c29e75f 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -30,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import UnifiConfigEntry -from .const import DOMAIN as UNIFI_DOMAIN +from .const import DOMAIN from .entity import ( HandlerT, UnifiEntity, @@ -204,14 +204,12 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) def update_unique_id(obj_id: str) -> None: """Rework unique ID.""" new_unique_id = f"{hub.site}-{obj_id}" - if ent_reg.async_get_entity_id( - DEVICE_TRACKER_DOMAIN, UNIFI_DOMAIN, new_unique_id - ): + if ent_reg.async_get_entity_id(DEVICE_TRACKER_DOMAIN, DOMAIN, new_unique_id): return unique_id = f"{obj_id}-{hub.site}" if entity_id := ent_reg.async_get_entity_id( - DEVICE_TRACKER_DOMAIN, UNIFI_DOMAIN, unique_id + DEVICE_TRACKER_DOMAIN, DOMAIN, unique_id ): ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index 9d4d92839fc..6cd652871d8 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from .const import DOMAIN as UNIFI_DOMAIN +from .const import DOMAIN SERVICE_RECONNECT_CLIENT = "reconnect_client" SERVICE_REMOVE_CLIENTS = "remove_clients" @@ -42,7 +42,7 @@ def async_setup_services(hass: HomeAssistant) -> None: for service in SUPPORTED_SERVICES: hass.services.async_register( - UNIFI_DOMAIN, + DOMAIN, service, async_call_unifi_service, schema=SERVICE_TO_SCHEMA.get(service), @@ -66,7 +66,7 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - if mac == "": return - for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN): + for config_entry in hass.config_entries.async_loaded_entries(DOMAIN): if ( (not (hub := config_entry.runtime_data).available) or (client := hub.api.clients.get(mac)) is None @@ -84,7 +84,7 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> - Total time between first seen and last seen is less than 15 minutes. - Neither IP, hostname nor name is configured. """ - for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN): + for config_entry in hass.config_entries.async_loaded_entries(DOMAIN): if not (hub := config_entry.runtime_data).available: continue diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 8f4f2b420a5..5b88055e62a 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -117,7 +117,7 @@ }, "remove_clients": { "name": "Remove clients from the UniFi Network", - "description": "Cleans up clients that has only been associated with the controller for a short period of time." + "description": "Cleans up clients that have only been associated with the controller for a short period of time." } } } diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 0d904d3c3ba..b55fef45229 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -65,13 +65,13 @@ MOUNT_DEVICE_CLASS_MAP = { CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="dark", - name="Is dark", + translation_key="is_dark", icon="mdi:brightness-6", ufp_value="is_dark", ), ProtectBinaryEntityDescription( key="ssh", - name="SSH enabled", + translation_key="ssh_enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -80,7 +80,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="status_light", - name="Status light on", + translation_key="status_light", icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_led_status", @@ -89,7 +89,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="hdr_mode", - name="HDR mode", + translation_key="hdr_mode", icon="mdi:brightness-7", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_hdr", @@ -98,7 +98,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="high_fps", - name="High FPS", + translation_key="high_fps", icon="mdi:video-high-definition", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_highfps", @@ -107,7 +107,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="system_sounds", - name="System sounds", + translation_key="system_sounds", icon="mdi:speaker", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="has_speaker", @@ -117,7 +117,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="osd_name", - name="Overlay: show name", + translation_key="overlay_show_name", icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_name_enabled", @@ -125,7 +125,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="osd_date", - name="Overlay: show date", + translation_key="overlay_show_date", icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_date_enabled", @@ -133,7 +133,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="osd_logo", - name="Overlay: show logo", + translation_key="overlay_show_logo", icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_logo_enabled", @@ -141,7 +141,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="osd_bitrate", - name="Overlay: show bitrate", + translation_key="overlay_show_nerd_mode", icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_debug_enabled", @@ -149,14 +149,14 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="motion_enabled", - name="Detections: motion", + translation_key="detections_motion", icon="mdi:run-fast", ufp_value="recording_settings.enable_motion_detection", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( key="smart_person", - name="Detections: person", + translation_key="detections_person", icon="mdi:walk", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_person", @@ -165,7 +165,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_vehicle", - name="Detections: vehicle", + translation_key="detections_vehicle", icon="mdi:car", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_vehicle", @@ -174,7 +174,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_animal", - name="Detections: animal", + translation_key="detections_animal", icon="mdi:paw", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_animal", @@ -183,7 +183,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_package", - name="Detections: package", + translation_key="detections_package", icon="mdi:package-variant-closed", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_package", @@ -192,7 +192,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_licenseplate", - name="Detections: license plate", + translation_key="detections_license_plate", icon="mdi:car", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_license_plate", @@ -201,7 +201,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_smoke", - name="Detections: smoke", + translation_key="detections_smoke", icon="mdi:fire", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_smoke", @@ -210,7 +210,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_cmonx", - name="Detections: CO", + translation_key="detections_co_alarm", icon="mdi:molecule-co", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_co", @@ -219,7 +219,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_siren", - name="Detections: siren", + translation_key="detections_siren", icon="mdi:alarm-bell", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_siren", @@ -228,7 +228,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_baby_cry", - name="Detections: baby cry", + translation_key="detections_baby_cry", icon="mdi:cradle", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_baby_cry", @@ -237,7 +237,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_speak", - name="Detections: speaking", + translation_key="detections_speaking", icon="mdi:account-voice", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_speaking", @@ -246,7 +246,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_bark", - name="Detections: barking", + translation_key="detections_barking", icon="mdi:dog", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_bark", @@ -255,7 +255,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_car_alarm", - name="Detections: car alarm", + translation_key="detections_car_alarm", icon="mdi:car", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_car_alarm", @@ -264,7 +264,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_car_horn", - name="Detections: car horn", + translation_key="detections_car_horn", icon="mdi:bugle", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_car_horn", @@ -273,7 +273,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_glass_break", - name="Detections: glass break", + translation_key="detections_glass_break", icon="mdi:glass-fragile", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_glass_break", @@ -282,7 +282,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="track_person", - name="Tracking: person", + translation_key="tracking_person", icon="mdi:walk", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.is_ptz", @@ -294,19 +294,18 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="dark", - name="Is dark", + translation_key="is_dark", icon="mdi:brightness-6", ufp_value="is_dark", ), ProtectBinaryEntityDescription( key="motion", - name="Motion detected", device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_pir_motion_detected", ), ProtectBinaryEntityDescription( key="light", - name="Flood light", + translation_key="flood_light", icon="mdi:spotlight-beam", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="is_light_on", @@ -314,7 +313,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="ssh", - name="SSH enabled", + translation_key="ssh_enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -323,7 +322,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="status_light", - name="Status light on", + translation_key="status_light", icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="light_device_settings.is_indicator_enabled", @@ -336,7 +335,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( MOUNTABLE_SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key=_KEY_DOOR, - name="Contact", + translation_key="contact", device_class=BinarySensorDeviceClass.DOOR, ufp_value="is_opened", ufp_enabled="is_contact_sensor_enabled", @@ -346,34 +345,30 @@ MOUNTABLE_SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="leak", - name="Leak", device_class=BinarySensorDeviceClass.MOISTURE, ufp_value="is_leak_detected", ufp_enabled="is_leak_sensor_enabled", ), ProtectBinaryEntityDescription( key="battery_low", - name="Battery low", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="battery_status.is_low", ), ProtectBinaryEntityDescription( key="motion", - name="Motion detected", device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_motion_detected", ufp_enabled="is_motion_sensor_enabled", ), ProtectBinaryEntityDescription( key="tampering", - name="Tampering detected", device_class=BinarySensorDeviceClass.TAMPER, ufp_value="is_tampering_detected", ), ProtectBinaryEntityDescription( key="status_light", - name="Status light on", + translation_key="status_light", icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="led_settings.is_enabled", @@ -381,7 +376,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="motion_enabled", - name="Motion detection", + translation_key="detections_motion", icon="mdi:walk", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.is_enabled", @@ -389,7 +384,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="temperature", - name="Temperature sensor", + translation_key="temperature_sensor", icon="mdi:thermometer", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="temperature_settings.is_enabled", @@ -397,7 +392,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="humidity", - name="Humidity sensor", + translation_key="humidity_sensor", icon="mdi:water-percent", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="humidity_settings.is_enabled", @@ -405,7 +400,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="light", - name="Light sensor", + translation_key="light_sensor", icon="mdi:brightness-5", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="light_settings.is_enabled", @@ -413,7 +408,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="alarm", - name="Alarm sound detection", + translation_key="alarm_sound_detection", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="alarm_settings.is_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -423,7 +418,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="doorbell", - name="Doorbell", + translation_key="doorbell", device_class=BinarySensorDeviceClass.OCCUPANCY, icon="mdi:doorbell-video", ufp_required_field="feature_flags.is_doorbell", @@ -431,14 +426,13 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="motion", - name="Motion", device_class=BinarySensorDeviceClass.MOTION, ufp_enabled="is_motion_detection_on", ufp_event_obj="last_motion_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_any", - name="Object detected", + translation_key="object_detected", icon="mdi:eye", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_detect_event", @@ -446,7 +440,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_obj_person", - name="Person detected", + translation_key="person_detected", icon="mdi:walk", ufp_obj_type=SmartDetectObjectType.PERSON, ufp_required_field="can_detect_person", @@ -455,7 +449,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_obj_vehicle", - name="Vehicle detected", + translation_key="vehicle_detected", icon="mdi:car", ufp_obj_type=SmartDetectObjectType.VEHICLE, ufp_required_field="can_detect_vehicle", @@ -464,7 +458,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_obj_animal", - name="Animal detected", + translation_key="animal_detected", icon="mdi:paw", ufp_obj_type=SmartDetectObjectType.ANIMAL, ufp_required_field="can_detect_animal", @@ -473,7 +467,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_obj_package", - name="Package detected", + translation_key="package_detected", icon="mdi:package-variant-closed", entity_registry_enabled_default=False, ufp_obj_type=SmartDetectObjectType.PACKAGE, @@ -483,7 +477,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_any", - name="Audio object detected", + translation_key="audio_object_detected", icon="mdi:eye", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_audio_detect_event", @@ -491,7 +485,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_smoke", - name="Smoke alarm detected", + translation_key="smoke_alarm_detected", icon="mdi:fire", ufp_obj_type=SmartDetectObjectType.SMOKE, ufp_required_field="can_detect_smoke", @@ -500,7 +494,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", - name="CO alarm detected", + translation_key="co_alarm_detected", icon="mdi:molecule-co", ufp_required_field="can_detect_co", ufp_enabled="is_co_detection_on", @@ -509,7 +503,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_siren", - name="Siren detected", + translation_key="siren_detected", icon="mdi:alarm-bell", ufp_obj_type=SmartDetectObjectType.SIREN, ufp_required_field="can_detect_siren", @@ -518,7 +512,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_baby_cry", - name="Baby cry detected", + translation_key="baby_cry_detected", icon="mdi:cradle", ufp_obj_type=SmartDetectObjectType.BABY_CRY, ufp_required_field="can_detect_baby_cry", @@ -527,7 +521,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_speak", - name="Speaking detected", + translation_key="speaking_detected", icon="mdi:account-voice", ufp_obj_type=SmartDetectObjectType.SPEAK, ufp_required_field="can_detect_speaking", @@ -536,7 +530,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_bark", - name="Barking detected", + translation_key="barking_detected", icon="mdi:dog", ufp_obj_type=SmartDetectObjectType.BARK, ufp_required_field="can_detect_bark", @@ -545,7 +539,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_car_alarm", - name="Car alarm detected", + translation_key="car_alarm_detected", icon="mdi:car", ufp_obj_type=SmartDetectObjectType.BURGLAR, ufp_required_field="can_detect_car_alarm", @@ -554,7 +548,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_car_horn", - name="Car horn detected", + translation_key="car_horn_detected", icon="mdi:bugle", ufp_obj_type=SmartDetectObjectType.CAR_HORN, ufp_required_field="can_detect_car_horn", @@ -563,7 +557,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ), ProtectBinaryEventEntityDescription( key="smart_audio_glass_break", - name="Glass break detected", + translation_key="glass_break_detected", icon="mdi:glass-fragile", ufp_obj_type=SmartDetectObjectType.GLASS_BREAK, ufp_required_field="can_detect_glass_break", @@ -575,14 +569,13 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="battery_low", - name="Battery low", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="battery_status.is_low", ), ProtectBinaryEntityDescription( key="status_light", - name="Status light on", + translation_key="status_light", icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="led_settings.is_enabled", @@ -593,7 +586,7 @@ DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( VIEWER_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="ssh", - name="SSH enabled", + translation_key="ssh_enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 7b766299946..2842f38d8a6 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -52,14 +52,13 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( key="reboot", entity_registry_enabled_default=False, device_class=ButtonDeviceClass.RESTART, - name="Reboot device", ufp_press="reboot", ufp_perm=PermRequired.WRITE, ), ProtectButtonEntityDescription( key="unadopt", + translation_key="unadopt_device", entity_registry_enabled_default=False, - name="Unadopt device", icon="mdi:delete", ufp_press="unadopt", ufp_perm=PermRequired.DELETE, @@ -68,7 +67,7 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ADOPT_BUTTON = ProtectButtonEntityDescription[ProtectAdoptableDeviceModel]( key="adopt", - name="Adopt device", + translation_key="adopt_device", icon="mdi:plus-circle", ufp_press="adopt", ) @@ -76,7 +75,7 @@ ADOPT_BUTTON = ProtectButtonEntityDescription[ProtectAdoptableDeviceModel]( SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="clear_tamper", - name="Clear tamper", + translation_key="clear_tamper", icon="mdi:notification-clear-all", ufp_press="clear_tamper", ufp_perm=PermRequired.WRITE, @@ -86,14 +85,14 @@ SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( CHIME_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="play", - name="Play chime", + translation_key="play_chime", device_class=DEVICE_CLASS_CHIME_BUTTON, icon="mdi:play", ufp_press="play", ), ProtectButtonEntityDescription( key="play_buzzer", - name="Play buzzer", + translation_key="play_buzzer", icon="mdi:play", ufp_press="play_buzzer", ), diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index e23568480ca..64bb278a8e2 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.6.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.11.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index a1e60931026..2c2948823d0 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -29,7 +29,9 @@ from .entity import ProtectDeviceEntity _LOGGER = logging.getLogger(__name__) _SPEAKER_DESCRIPTION = MediaPlayerEntityDescription( - key="speaker", name="Speaker", device_class=MediaPlayerDeviceClass.SPEAKER + key="speaker", + translation_key="speaker", + device_class=MediaPlayerDeviceClass.SPEAKER, ) diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 5dbf9f2b00e..0f0790105c5 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -64,7 +64,7 @@ def _get_chime_duration(obj: Camera) -> int: CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="wdr_value", - name="Wide dynamic range", + translation_key="wide_dynamic_range", icon="mdi:state-machine", entity_category=EntityCategory.CONFIG, ufp_min=0, @@ -77,7 +77,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription( key="mic_level", - name="Microphone level", + translation_key="microphone_level", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -92,7 +92,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription( key="zoom_position", - name="Zoom level", + translation_key="zoom_level", icon="mdi:magnify-plus-outline", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -106,7 +106,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription( key="chime_duration", - name="Chime duration", + translation_key="chime_duration", icon="mdi:bell", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -121,7 +121,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription( key="icr_lux", - name="Infrared custom lux trigger", + translation_key="infrared_custom_lux_trigger", icon="mdi:white-balance-sunny", entity_category=EntityCategory.CONFIG, ufp_min=0, @@ -138,7 +138,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="sensitivity", - name="Motion sensitivity", + translation_key="motion_sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -152,7 +152,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription[Light]( key="duration", - name="Auto-shutoff duration", + translation_key="auto_shutoff_duration", icon="mdi:camera-timer", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -169,7 +169,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="sensitivity", - name="Motion sensitivity", + translation_key="motion_sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -186,7 +186,7 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription[Doorlock]( key="auto_lock_time", - name="Auto-lock timeout", + translation_key="auto_lock_timeout", icon="mdi:walk", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -203,7 +203,7 @@ DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="volume", - name="Volume", + translation_key="volume", icon="mdi:speaker", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 054c9430387..168fab584fa 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -193,7 +193,7 @@ async def _set_liveview(obj: Viewer, liveview_id: str) -> None: CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="recording_mode", - name="Recording mode", + translation_key="recording_mode", icon="mdi:video-outline", entity_category=EntityCategory.CONFIG, ufp_options=DEVICE_RECORDING_MODES, @@ -204,7 +204,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription( key="infrared", - name="Infrared mode", + translation_key="infrared_mode", icon="mdi:circle-opacity", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_ir", @@ -216,7 +216,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription[Camera]( key="doorbell_text", - name="Doorbell text", + translation_key="doorbell_text", icon="mdi:card-text", entity_category=EntityCategory.CONFIG, device_class=DEVICE_CLASS_LCD_MESSAGE, @@ -228,7 +228,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription( key="chime_type", - name="Chime type", + translation_key="chime_type", icon="mdi:bell", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_chime", @@ -240,7 +240,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription( key="hdr_mode", - name="HDR mode", + translation_key="hdr_mode", icon="mdi:brightness-7", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_hdr", @@ -254,7 +254,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Light]( key=_KEY_LIGHT_MOTION, - name="Light mode", + translation_key="light_mode", icon="mdi:spotlight", entity_category=EntityCategory.CONFIG, ufp_options=MOTION_MODE_TO_LIGHT_MODE, @@ -264,7 +264,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription[Light]( key="paired_camera", - name="Paired camera", + translation_key="paired_camera", icon="mdi:cctv", entity_category=EntityCategory.CONFIG, ufp_value="camera_id", @@ -277,7 +277,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="mount_type", - name="Mount type", + translation_key="mount_type", icon="mdi:screwdriver", entity_category=EntityCategory.CONFIG, ufp_options=MOUNT_TYPES, @@ -288,7 +288,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription[Sensor]( key="paired_camera", - name="Paired camera", + translation_key="paired_camera", icon="mdi:cctv", entity_category=EntityCategory.CONFIG, ufp_value="camera_id", @@ -301,7 +301,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( DOORLOCK_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Doorlock]( key="paired_camera", - name="Paired camera", + translation_key="paired_camera", icon="mdi:cctv", entity_category=EntityCategory.CONFIG, ufp_value="camera_id", @@ -314,7 +314,7 @@ DOORLOCK_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Viewer]( key="viewer", - name="Liveview", + translation_key="liveview", icon="mdi:view-dashboard", entity_category=None, ufp_options_fn=_get_viewer_options, diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index a719f36c2b3..f25a0302669 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -125,7 +125,7 @@ def _get_alarm_sound(obj: Sensor) -> str: ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, @@ -134,7 +134,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="ble_signal", - name="Bluetooth signal strength", + translation_key="bluetooth_signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, @@ -145,7 +145,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="phy_rate", - name="Link speed", + translation_key="link_speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, entity_category=EntityCategory.DIAGNOSTIC, @@ -156,7 +156,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="wifi_signal", - name="WiFi signal strength", + translation_key="wifi_signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, @@ -170,7 +170,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="oldest_recording", - name="Oldest recording", + translation_key="oldest_recording", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -178,7 +178,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="storage_used", - name="Storage used", + translation_key="storage_used", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -189,7 +189,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="write_rate", - name="Disk write rate", + translation_key="disk_write_rate", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, entity_category=EntityCategory.DIAGNOSTIC, @@ -201,7 +201,6 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="voltage", - name="Voltage", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_category=EntityCategory.DIAGNOSTIC, @@ -214,7 +213,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="doorbell_last_trip_time", - name="Last doorbell ring", + translation_key="last_doorbell_ring", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:doorbell-video", ufp_required_field="feature_flags.is_doorbell", @@ -223,7 +222,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="lens_type", - name="Lens type", + translation_key="lens_type", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:camera-iris", ufp_required_field="has_removable_lens", @@ -231,7 +230,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="mic_level", - name="Microphone level", + translation_key="microphone_level", icon="mdi:microphone", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -242,7 +241,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="recording_mode", - name="Recording mode", + translation_key="recording_mode", icon="mdi:video-outline", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="recording_settings.mode.value", @@ -250,7 +249,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="infrared", - name="Infrared mode", + translation_key="infrared_mode", icon="mdi:circle-opacity", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_led_ir", @@ -259,7 +258,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="doorbell_text", - name="Doorbell text", + translation_key="doorbell_text", icon="mdi:card-text", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_lcd_screen", @@ -268,7 +267,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="chime_type", - name="Chime type", + translation_key="chime_type", icon="mdi:bell", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -280,7 +279,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( CAMERA_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="stats_rx", - name="Received data", + translation_key="received_data", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, @@ -292,7 +291,7 @@ CAMERA_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="stats_tx", - name="Transferred data", + translation_key="transferred_data", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, @@ -307,7 +306,6 @@ CAMERA_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="battery_level", - name="Battery level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -316,7 +314,6 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="light_level", - name="Light level", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, @@ -325,7 +322,6 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="humidity_level", - name="Humidity level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -334,7 +330,6 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="temperature_level", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -343,34 +338,34 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription[Sensor]( key="alarm_sound", - name="Alarm sound detected", + translation_key="alarm_sound_detected", ufp_value_fn=_get_alarm_sound, ufp_enabled="is_alarm_sensor_enabled", ), ProtectSensorEntityDescription( key="door_last_trip_time", - name="Last open", + translation_key="last_open", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="open_status_changed_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="motion_last_trip_time", - name="Last motion detected", + translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="motion_detected_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="tampering_last_trip_time", - name="Last tampering detected", + translation_key="last_tampering_detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="tampering_detected_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="sensitivity", - name="Motion sensitivity", + translation_key="sensitivity", icon="mdi:walk", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -379,7 +374,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="mount_type", - name="Mount type", + translation_key="mount_type", icon="mdi:screwdriver", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="mount_type", @@ -387,7 +382,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="paired_camera", - name="Paired camera", + translation_key="paired_camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", @@ -398,7 +393,6 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="battery_level", - name="Battery level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -407,7 +401,7 @@ DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="paired_camera", - name="Paired camera", + translation_key="paired_camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", @@ -418,7 +412,7 @@ DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, @@ -426,7 +420,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="storage_utilization", - name="Storage utilization", + translation_key="storage_utilization", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", entity_category=EntityCategory.DIAGNOSTIC, @@ -436,7 +430,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="record_rotating", - name="Type: timelapse video", + translation_key="type_timelapse_video", native_unit_of_measurement=PERCENTAGE, icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, @@ -446,7 +440,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="record_timelapse", - name="Type: continuous video", + translation_key="type_continuous_video", native_unit_of_measurement=PERCENTAGE, icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, @@ -456,7 +450,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="record_detections", - name="Type: detections video", + translation_key="type_detections_video", native_unit_of_measurement=PERCENTAGE, icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, @@ -466,7 +460,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="resolution_HD", - name="Resolution: HD video", + translation_key="resolution_hd_video", native_unit_of_measurement=PERCENTAGE, icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, @@ -476,7 +470,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="resolution_4K", - name="Resolution: 4K video", + translation_key="resolution_4k_video", native_unit_of_measurement=PERCENTAGE, icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, @@ -486,7 +480,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="resolution_free", - name="Resolution: free space", + translation_key="resolution_free_space", native_unit_of_measurement=PERCENTAGE, icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, @@ -496,7 +490,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription[NVR]( key="record_capacity", - name="Recording capacity", + translation_key="recording_capacity", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:record-rec", entity_category=EntityCategory.DIAGNOSTIC, @@ -508,7 +502,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="cpu_utilization", - name="CPU utilization", + translation_key="cpu_utilization", native_unit_of_measurement=PERCENTAGE, icon="mdi:speedometer", entity_registry_enabled_default=False, @@ -518,7 +512,7 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="cpu_temperature", - name="CPU temperature", + translation_key="cpu_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, @@ -528,7 +522,7 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription[NVR]( key="memory_utilization", - name="Memory utilization", + translation_key="memory_utilization", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", entity_registry_enabled_default=False, @@ -542,9 +536,8 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( LICENSE_PLATE_EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( ProtectSensorEventEntityDescription( key="smart_obj_licenseplate", - name="License plate detected", icon="mdi:car", - translation_key="license_plate", + translation_key="license_plate_detected", ufp_obj_type=SmartDetectObjectType.LICENSE_PLATE, ufp_required_field="can_detect_license_plate", ufp_event_obj="last_license_plate_detect_event", @@ -555,14 +548,14 @@ LICENSE_PLATE_EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="motion_last_trip_time", - name="Last motion detected", + translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="last_motion", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="sensitivity", - name="Motion sensitivity", + translation_key="motion_sensitivity", icon="mdi:walk", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -571,7 +564,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription[Light]( key="light_motion", - name="Light mode", + translation_key="light_mode", icon="mdi:spotlight", entity_category=EntityCategory.DIAGNOSTIC, ufp_value_fn=async_get_light_motion_current, @@ -579,7 +572,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="paired_camera", - name="Paired camera", + translation_key="paired_camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", @@ -590,7 +583,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( MOTION_TRIP_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="motion_last_trip_time", - name="Last motion detected", + translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="last_motion", entity_registry_enabled_default=False, @@ -600,14 +593,14 @@ MOTION_TRIP_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( CHIME_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="last_ring", - name="Last ring", + translation_key="last_ring", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:bell", ufp_value="last_ring", ), ProtectSensorEntityDescription( key="volume", - name="Volume", + translation_key="volume", icon="mdi:speaker", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -619,7 +612,7 @@ CHIME_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( VIEWER_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="viewer", - name="Liveview", + translation_key="liveview", icon="mdi:view-dashboard", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="liveview.name", diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index d5a7d615399..46a60f4abfd 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -128,16 +128,469 @@ } }, "entity": { + "binary_sensor": { + "is_dark": { + "name": "Is dark" + }, + "ssh_enabled": { + "name": "SSH enabled" + }, + "status_light": { + "name": "Status light" + }, + "hdr_mode": { + "name": "HDR mode" + }, + "high_fps": { + "name": "High FPS" + }, + "system_sounds": { + "name": "System sounds" + }, + "overlay_show_name": { + "name": "Overlay: show name" + }, + "overlay_show_date": { + "name": "Overlay: show date" + }, + "overlay_show_logo": { + "name": "Overlay: show logo" + }, + "overlay_show_nerd_mode": { + "name": "Overlay: show nerd mode" + }, + "detections_motion": { + "name": "Detections: motion" + }, + "detections_person": { + "name": "Detections: person" + }, + "detections_vehicle": { + "name": "Detections: vehicle" + }, + "detections_animal": { + "name": "Detections: animal" + }, + "detections_package": { + "name": "Detections: package" + }, + "detections_license_plate": { + "name": "Detections: license plate" + }, + "detections_smoke": { + "name": "Detections: smoke" + }, + "detections_co_alarm": { + "name": "Detections: CO alarm" + }, + "detections_siren": { + "name": "Detections: siren" + }, + "detections_baby_cry": { + "name": "Detections: baby cry" + }, + "detections_speaking": { + "name": "Detections: speaking" + }, + "detections_barking": { + "name": "Detections: barking" + }, + "detections_car_alarm": { + "name": "Detections: car alarm" + }, + "detections_car_horn": { + "name": "Detections: car horn" + }, + "detections_glass_break": { + "name": "Detections: glass break" + }, + "tracking_person": { + "name": "Tracking: person" + }, + "flood_light": { + "name": "Flood light" + }, + "contact": { + "name": "Contact" + }, + "temperature_sensor": { + "name": "Temperature sensor" + }, + "humidity_sensor": { + "name": "Humidity sensor" + }, + "light_sensor": { + "name": "Light sensor" + }, + "alarm_sound_detection": { + "name": "Alarm sound detection" + }, + "doorbell": { + "name": "[%key:component::event::entity_component::doorbell::name%]" + }, + "object_detected": { + "name": "Object detected" + }, + "person_detected": { + "name": "Person detected" + }, + "vehicle_detected": { + "name": "Vehicle detected" + }, + "animal_detected": { + "name": "Animal detected" + }, + "package_detected": { + "name": "Package detected" + }, + "audio_object_detected": { + "name": "Audio object detected" + }, + "smoke_alarm_detected": { + "name": "Smoke alarm detected" + }, + "co_alarm_detected": { + "name": "CO alarm detected" + }, + "siren_detected": { + "name": "Siren detected" + }, + "baby_cry_detected": { + "name": "Baby cry detected" + }, + "speaking_detected": { + "name": "Speaking detected" + }, + "barking_detected": { + "name": "Barking detected" + }, + "car_alarm_detected": { + "name": "Car alarm detected" + }, + "car_horn_detected": { + "name": "Car horn detected" + }, + "glass_break_detected": { + "name": "Glass break detected" + } + }, + "button": { + "unadopt_device": { + "name": "Unadopt device" + }, + "adopt_device": { + "name": "Adopt device" + }, + "clear_tamper": { + "name": "Clear tamper" + }, + "play_chime": { + "name": "Play chime" + }, + "play_buzzer": { + "name": "Play buzzer" + } + }, + "media_player": { + "speaker": { + "name": "[%key:component::media_player::entity_component::speaker::name%]" + } + }, + "number": { + "wide_dynamic_range": { + "name": "Wide dynamic range" + }, + "microphone_level": { + "name": "Microphone level" + }, + "zoom_level": { + "name": "Zoom level" + }, + "chime_duration": { + "name": "Chime duration" + }, + "infrared_custom_lux_trigger": { + "name": "Infrared custom lux trigger" + }, + "motion_sensitivity": { + "name": "Motion sensitivity" + }, + "auto_shutoff_duration": { + "name": "Auto-shutoff duration" + }, + "auto_lock_timeout": { + "name": "Auto-lock timeout" + }, + "volume": { + "name": "[%key:component::sensor::entity_component::volume::name%]" + } + }, + "select": { + "recording_mode": { + "name": "Recording mode" + }, + "infrared_mode": { + "name": "Infrared mode" + }, + "doorbell_text": { + "name": "Doorbell text" + }, + "chime_type": { + "name": "Chime type" + }, + "hdr_mode": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::hdr_mode::name%]" + }, + "light_mode": { + "name": "Light mode" + }, + "paired_camera": { + "name": "Paired camera" + }, + "mount_type": { + "name": "Mount type" + }, + "liveview": { + "name": "Liveview" + } + }, "sensor": { - "license_plate": { + "uptime": { + "name": "Uptime" + }, + "bluetooth_signal_strength": { + "name": "Bluetooth signal strength" + }, + "link_speed": { + "name": "Link speed" + }, + "wifi_signal_strength": { + "name": "WiFi signal strength" + }, + "oldest_recording": { + "name": "Oldest recording" + }, + "storage_used": { + "name": "Storage used" + }, + "disk_write_rate": { + "name": "Disk write rate" + }, + "last_doorbell_ring": { + "name": "Last doorbell ring" + }, + "lens_type": { + "name": "Lens type" + }, + "microphone_level": { + "name": "[%key:component::unifiprotect::entity::number::microphone_level::name%]" + }, + "recording_mode": { + "name": "[%key:component::unifiprotect::entity::select::recording_mode::name%]" + }, + "infrared_mode": { + "name": "[%key:component::unifiprotect::entity::select::infrared_mode::name%]" + }, + "doorbell_text": { + "name": "[%key:component::unifiprotect::entity::select::doorbell_text::name%]" + }, + "chime_type": { + "name": "[%key:component::unifiprotect::entity::select::chime_type::name%]" + }, + "received_data": { + "name": "Received data" + }, + "transferred_data": { + "name": "Transferred data" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "alarm_sound_detected": { + "name": "Alarm sound detected" + }, + "last_open": { + "name": "Last open" + }, + "last_motion_detected": { + "name": "Last motion detected" + }, + "last_tampering_detected": { + "name": "Last tampering detected" + }, + "motion_sensitivity": { + "name": "[%key:component::unifiprotect::entity::number::motion_sensitivity::name%]" + }, + "mount_type": { + "name": "[%key:component::unifiprotect::entity::select::mount_type::name%]" + }, + "paired_camera": { + "name": "[%key:component::unifiprotect::entity::select::paired_camera::name%]" + }, + "storage_utilization": { + "name": "Storage utilization" + }, + "type_timelapse_video": { + "name": "Type: timelapse video" + }, + "type_continuous_video": { + "name": "Type: continuous video" + }, + "type_detections_video": { + "name": "Type: detections video" + }, + "resolution_hd_video": { + "name": "Resolution: HD video" + }, + "resolution_4k_video": { + "name": "Resolution: 4K video" + }, + "resolution_free_space": { + "name": "Resolution: free space" + }, + "recording_capacity": { + "name": "Recording capacity" + }, + "cpu_utilization": { + "name": "CPU utilization" + }, + "cpu_temperature": { + "name": "CPU temperature" + }, + "memory_utilization": { + "name": "Memory utilization" + }, + "license_plate_detected": { + "name": "License plate detected", "state": { - "none": "Clear" + "none": "[%key:component::binary_sensor::entity_component::gas::state::off%]" } + }, + "light_mode": { + "name": "[%key:component::unifiprotect::entity::select::light_mode::name%]" + }, + "last_ring": { + "name": "Last ring" + }, + "volume": { + "name": "[%key:component::sensor::entity_component::volume::name%]" + }, + "liveview": { + "name": "[%key:component::unifiprotect::entity::select::liveview::name%]" + } + }, + "switch": { + "ssh_enabled": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::ssh_enabled::name%]" + }, + "status_light": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::status_light::name%]" + }, + "hdr_mode": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::hdr_mode::name%]" + }, + "high_fps": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::high_fps::name%]" + }, + "system_sounds": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::system_sounds::name%]" + }, + "overlay_show_name": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::overlay_show_name::name%]" + }, + "overlay_show_date": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::overlay_show_date::name%]" + }, + "overlay_show_logo": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::overlay_show_logo::name%]" + }, + "overlay_show_nerd_mode": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::overlay_show_nerd_mode::name%]" + }, + "color_night_vision": { + "name": "Color night vision" + }, + "motion": { + "name": "[%key:component::binary_sensor::entity_component::motion::name%]" + }, + "detections_motion": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_motion::name%]" + }, + "detections_person": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_person::name%]" + }, + "detections_vehicle": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_vehicle::name%]" + }, + "detections_animal": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_animal::name%]" + }, + "detections_package": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_package::name%]" + }, + "detections_license_plate": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_license_plate::name%]" + }, + "detections_smoke": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_smoke::name%]" + }, + "detections_co_alarm": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_co_alarm::name%]" + }, + "detections_siren": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_siren::name%]" + }, + "detections_baby_cry": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_baby_cry::name%]" + }, + "detections_speak": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_speaking::name%]" + }, + "detections_barking": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_barking::name%]" + }, + "detections_car_alarm": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_car_alarm::name%]" + }, + "detections_car_horn": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_car_horn::name%]" + }, + "detections_glass_break": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_glass_break::name%]" + }, + "tracking_person": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::tracking_person::name%]" + }, + "privacy_mode": { + "name": "Privacy mode" + }, + "temperature_sensor": { + "name": "Temperature sensor" + }, + "humidity_sensor": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::humidity_sensor::name%]" + }, + "light_sensor": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::light_sensor::name%]" + }, + "alarm_sound_detection": { + "name": "[%key:component::unifiprotect::entity::binary_sensor::alarm_sound_detection::name%]" + }, + "analytics_enabled": { + "name": "Analytics enabled" + }, + "insights_enabled": { + "name": "Insights enabled" + } + }, + "text": { + "doorbell": { + "name": "[%key:component::event::entity_component::doorbell::name%]" } }, "event": { "doorbell": { - "name": "Doorbell", + "name": "[%key:component::event::entity_component::doorbell::name%]", "state_attributes": { "event_type": { "state": { @@ -217,7 +670,7 @@ "description": "Removes a privacy zone from a camera.", "fields": { "device_id": { - "name": "Camera", + "name": "[%key:component::camera::title%]", "description": "Camera you want to remove the privacy zone from." }, "name": { diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index fce92912a52..29dffa97c3a 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -52,7 +52,7 @@ async def _set_highfps(obj: Camera, value: bool) -> None: CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="ssh", - name="SSH enabled", + translation_key="ssh_enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, @@ -62,7 +62,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="status_light", - name="Status light on", + translation_key="status_light", icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_status", @@ -72,7 +72,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="hdr_mode", - name="HDR mode", + translation_key="hdr_mode", icon="mdi:brightness-7", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -83,7 +83,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription[Camera]( key="high_fps", - name="High FPS", + translation_key="high_fps", icon="mdi:video-high-definition", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_highfps", @@ -93,7 +93,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="system_sounds", - name="System sounds", + translation_key="system_sounds", icon="mdi:speaker", entity_category=EntityCategory.CONFIG, ufp_required_field="has_speaker", @@ -104,7 +104,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="osd_name", - name="Overlay: show name", + translation_key="overlay_show_name", icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_name_enabled", @@ -113,7 +113,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="osd_date", - name="Overlay: show date", + translation_key="overlay_show_date", icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_date_enabled", @@ -122,7 +122,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="osd_logo", - name="Overlay: show logo", + translation_key="overlay_show_logo", icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_logo_enabled", @@ -131,7 +131,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="osd_bitrate", - name="Overlay: show nerd mode", + translation_key="overlay_show_nerd_mode", icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_debug_enabled", @@ -140,7 +140,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="color_night_vision", - name="Color night vision", + translation_key="color_night_vision", icon="mdi:light-flood-down", entity_category=EntityCategory.CONFIG, ufp_required_field="has_color_night_vision", @@ -150,7 +150,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="motion", - name="Detections: motion", + translation_key="motion", icon="mdi:run-fast", entity_category=EntityCategory.CONFIG, ufp_value="recording_settings.enable_motion_detection", @@ -160,7 +160,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_person", - name="Detections: person", + translation_key="detections_person", icon="mdi:walk", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_person", @@ -171,7 +171,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_vehicle", - name="Detections: vehicle", + translation_key="detections_vehicle", icon="mdi:car", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_vehicle", @@ -182,7 +182,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_animal", - name="Detections: animal", + translation_key="detections_animal", icon="mdi:paw", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_animal", @@ -193,7 +193,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_package", - name="Detections: package", + translation_key="detections_package", icon="mdi:package-variant-closed", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_package", @@ -204,7 +204,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_licenseplate", - name="Detections: license plate", + translation_key="detections_license_plate", icon="mdi:car", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_license_plate", @@ -215,7 +215,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_smoke", - name="Detections: smoke", + translation_key="detections_smoke", icon="mdi:fire", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_smoke", @@ -226,7 +226,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_cmonx", - name="Detections: CO", + translation_key="detections_co_alarm", icon="mdi:molecule-co", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_co", @@ -237,7 +237,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_siren", - name="Detections: siren", + translation_key="detections_siren", icon="mdi:alarm-bell", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_siren", @@ -248,7 +248,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_baby_cry", - name="Detections: baby cry", + translation_key="detections_baby_cry", icon="mdi:cradle", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_baby_cry", @@ -259,7 +259,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_speak", - name="Detections: speaking", + translation_key="detections_speak", icon="mdi:account-voice", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_speaking", @@ -270,7 +270,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_bark", - name="Detections: barking", + translation_key="detections_bark", icon="mdi:dog", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_bark", @@ -281,7 +281,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_car_alarm", - name="Detections: car alarm", + translation_key="detections_car_alarm", icon="mdi:car", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_car_alarm", @@ -292,7 +292,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_car_horn", - name="Detections: car horn", + translation_key="detections_car_horn", icon="mdi:bugle", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_car_horn", @@ -303,7 +303,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_glass_break", - name="Detections: glass break", + translation_key="detections_glass_break", icon="mdi:glass-fragile", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_glass_break", @@ -314,7 +314,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="track_person", - name="Tracking: person", + translation_key="tracking_person", icon="mdi:walk", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.is_ptz", @@ -326,7 +326,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( key="privacy_mode", - name="Privacy mode", + translation_key="privacy_mode", icon="mdi:eye-settings", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_privacy_mask", @@ -337,7 +337,7 @@ PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="status_light", - name="Status light on", + translation_key="status_light", icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", @@ -346,7 +346,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="motion", - name="Motion detection", + translation_key="detections_motion", icon="mdi:walk", entity_category=EntityCategory.CONFIG, ufp_value="motion_settings.is_enabled", @@ -355,7 +355,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="temperature", - name="Temperature sensor", + translation_key="temperature_sensor", icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ufp_value="temperature_settings.is_enabled", @@ -364,7 +364,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="humidity", - name="Humidity sensor", + translation_key="humidity_sensor", icon="mdi:water-percent", entity_category=EntityCategory.CONFIG, ufp_value="humidity_settings.is_enabled", @@ -373,7 +373,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="light", - name="Light sensor", + translation_key="light_sensor", icon="mdi:brightness-5", entity_category=EntityCategory.CONFIG, ufp_value="light_settings.is_enabled", @@ -382,7 +382,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="alarm", - name="Alarm sound detection", + translation_key="alarm_sound_detection", entity_category=EntityCategory.CONFIG, ufp_value="alarm_settings.is_enabled", ufp_set_method="set_alarm_status", @@ -394,7 +394,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="ssh", - name="SSH enabled", + translation_key="ssh_enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, @@ -404,7 +404,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="status_light", - name="Status light on", + translation_key="status_light", icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_value="light_device_settings.is_indicator_enabled", @@ -416,7 +416,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( DOORLOCK_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="status_light", - name="Status light on", + translation_key="status_light", icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", @@ -428,7 +428,7 @@ DOORLOCK_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( VIEWER_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="ssh", - name="SSH enabled", + translation_key="ssh_enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, @@ -441,7 +441,7 @@ VIEWER_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="analytics_enabled", - name="Analytics enabled", + translation_key="analytics_enabled", icon="mdi:google-analytics", entity_category=EntityCategory.CONFIG, ufp_value="is_analytics_enabled", @@ -449,7 +449,7 @@ NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="insights_enabled", - name="Insights enabled", + translation_key="insights_enabled", icon="mdi:magnify", entity_category=EntityCategory.CONFIG, ufp_value="is_insights_enabled", diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 1c468d44cc6..2e11c201f5f 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -46,7 +46,7 @@ async def _set_doorbell_message(obj: Camera, message: str) -> None: CAMERA: tuple[ProtectTextEntityDescription, ...] = ( ProtectTextEntityDescription( key="doorbell", - name="Doorbell", + translation_key="doorbell", entity_category=EntityCategory.CONFIG, ufp_value_fn=_get_doorbell_current, ufp_set_method_fn=_set_doorbell_message, diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index 2f6225fa498..7ecb1ee3313 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -39,7 +39,6 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon name=DOMAIN, update_interval=COORDINATOR_UPDATE_INTERVAL, ) - self._device_registry = dr.async_get(hass) self.api = api async def _async_update_data(self) -> list[UptimeRobotMonitor]: @@ -56,23 +55,21 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon monitors: list[UptimeRobotMonitor] = response.data - current_monitors = { - list(device.identifiers)[0][1] - for device in dr.async_entries_for_config_entry( - self._device_registry, self.config_entry.entry_id - ) - } + current_monitors = ( + {str(monitor.id) for monitor in self.data} if self.data else set() + ) new_monitors = {str(monitor.id) for monitor in monitors} if stale_monitors := current_monitors - new_monitors: for monitor_id in stale_monitors: - if device := self._device_registry.async_get_device( + device_registry = dr.async_get(self.hass) + if device := device_registry.async_get_device( identifiers={(DOMAIN, monitor_id)} ): - self._device_registry.async_remove_device(device.id) + device_registry.async_remove_device(device.id) # If there are new monitors, we should reload the config entry so we can # create new devices and entities. - if self.data and new_monitors - {str(monitor.id) for monitor in self.data}: + if self.data and new_monitors - current_monitors: self.hass.async_create_task( self.hass.config_entries.async_reload(self.config_entry.entry_id) ) diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index 43076320b8f..1244d6a4c19 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -26,9 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: we should not swallow the exception in switch.py + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index 6bcd1554b16..ffee6769c69 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -45,5 +45,10 @@ } } } + }, + "exceptions": { + "api_exception": { + "message": "Could not turn on/off monitoring: {error}" + } } } diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 9b25570393a..5d80903ed02 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -12,9 +12,10 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import API_ATTR_OK, LOGGER +from .const import API_ATTR_OK, DOMAIN from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity @@ -57,16 +58,21 @@ class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity): try: response = await self.api.async_edit_monitor(**kwargs) except UptimeRobotAuthenticationException: - LOGGER.debug("API authentication error, calling reauth") self.coordinator.config_entry.async_start_reauth(self.hass) return except UptimeRobotException as exception: - LOGGER.error("API exception: %s", exception) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_exception", + translation_placeholders={"error": repr(exception)}, + ) from exception if response.status != API_ATTR_OK: - LOGGER.error("API exception: %s", response.error.message, exc_info=True) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_exception", + translation_placeholders={"error": response.error.message}, + ) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index e2b3411c193..64fa3342c08 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -17,9 +17,11 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from homeassistant.helpers.typing import ConfigType from .const import ( @@ -217,6 +219,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_SOURCE_SENSOR] + ), + source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], + source_entity_removed=source_entity_removed, + ) + ) + if not entry.options.get(CONF_TARIFFS): # Only a single meter sensor is required hass.data[DATA_UTILITY][entry.entry_id][CONF_TARIFF_ENTITY] = None diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index cda538386c1..d424692ac95 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -605,7 +605,7 @@ class UtilityMeterSensor(RestoreSensor): self._attr_native_value = Decimal(str(value)) self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 35f94e54470..4ef7ccf62c2 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -21,7 +21,7 @@ "tls": "Enable this if you use a secure connection to your Velbus interface, like a Signum.", "host": "The IP address or hostname of the Velbus interface.", "port": "The port number of the Velbus interface.", - "password": "The password of the Velbus interface, this is only needed if the interface is password protected." + "password": "The password of the Velbus interface, this is only needed if the interface is password-protected." }, "description": "TCP/IP configuration, in case you use a Signum, VelServ, velbus-tcp or any other Velbus to TCP/IP interface." }, @@ -58,7 +58,7 @@ "services": { "sync_clock": { "name": "Sync clock", - "description": "Syncs the Velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", + "description": "Syncs the clock of the Velbus modules to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", "fields": { "interface": { "name": "Interface", @@ -104,7 +104,7 @@ }, "set_memo_text": { "name": "Set memo text", - "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD Be sure the page(s) of the module is configured to display the memo text.", + "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD. Be sure the pages of the modules are configured to display the memo text.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index ade86e8dd71..67fa08fcc12 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.climate import ( @@ -111,8 +113,11 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): _attr_fan_modes = [FAN_ON, FAN_AUTO] _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF, HVACMode.AUTO] + _attr_preset_modes = [PRESET_NONE, PRESET_AWAY, HOLD_MODE_TEMPERATURE] _attr_precision = PRECISION_HALVES _attr_name = None + _attr_min_humidity = 0 # Hardcoded to 0 in API. + _attr_max_humidity = 60 # Hardcoded to 60 in API. def __init__( self, @@ -155,12 +160,12 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): return UnitOfTemperature.CELSIUS @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._client.get_indoor_temp() @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" return self._client.get_indoor_humidity() @@ -187,14 +192,14 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): return HVACAction.OFF @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the current fan mode.""" if self._client.fan == self._client.FAN_ON: return FAN_ON return FAN_AUTO @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" return { ATTR_FAN_STATE: self._client.fanstate, @@ -202,7 +207,7 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): } @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the target temperature we try to reach.""" if self._client.mode == self._client.MODE_HEAT: return self._client.heattemp @@ -211,36 +216,26 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): return None @property - def target_temperature_low(self): + def target_temperature_low(self) -> float | None: """Return the lower bound temp if auto mode is on.""" if self._client.mode == self._client.MODE_AUTO: return self._client.heattemp return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> float | None: """Return the upper bound temp if auto mode is on.""" if self._client.mode == self._client.MODE_AUTO: return self._client.cooltemp return None @property - def target_humidity(self): + def target_humidity(self) -> float | None: """Return the humidity we try to reach.""" return self._client.hum_setpoint @property - def min_humidity(self): - """Return the minimum humidity. Hardcoded to 0 in API.""" - return 0 - - @property - def max_humidity(self): - """Return the maximum humidity. Hardcoded to 60 in API.""" - return 60 - - @property - def preset_mode(self): + def preset_mode(self) -> str: """Return current preset.""" if self._client.away: return PRESET_AWAY @@ -248,11 +243,6 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): return HOLD_MODE_TEMPERATURE return PRESET_NONE - @property - def preset_modes(self): - """Return valid preset modes.""" - return [PRESET_NONE, PRESET_AWAY, HOLD_MODE_TEMPERATURE] - def _set_operation_mode(self, operation_mode: HVACMode): """Change the operation mode (internal).""" if operation_mode == HVACMode.HEAT: @@ -268,32 +258,28 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): _LOGGER.error("Failed to change the operation mode") return success - def set_temperature(self, **kwargs): + def set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" set_temp = True - operation_mode = kwargs.get(ATTR_HVAC_MODE) - temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) - temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - temperature = kwargs.get(ATTR_TEMPERATURE) + operation_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) + temp_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW) + temp_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temperature: float | None = kwargs.get(ATTR_TEMPERATURE) - if operation_mode and self._mode_map.get(operation_mode) != self._client.mode: + client_mode = self._client.mode + if ( + operation_mode + and (new_mode := self._mode_map.get(operation_mode)) != client_mode + ): set_temp = self._set_operation_mode(operation_mode) + client_mode = new_mode if set_temp: - if ( - self._mode_map.get(operation_mode, self._client.mode) - == self._client.MODE_HEAT - ): + if client_mode == self._client.MODE_HEAT: success = self._client.set_setpoints(temperature, self._client.cooltemp) - elif ( - self._mode_map.get(operation_mode, self._client.mode) - == self._client.MODE_COOL - ): + elif client_mode == self._client.MODE_COOL: success = self._client.set_setpoints(self._client.heattemp, temperature) - elif ( - self._mode_map.get(operation_mode, self._client.mode) - == self._client.MODE_AUTO - ): + elif client_mode == self._client.MODE_AUTO: success = self._client.set_setpoints(temp_low, temp_high) else: success = False diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index 051f17262a0..6241225ed4e 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "description": "Sign-in with your Verisure My Pages account.", + "description": "Sign in with your Verisure My Pages account.", "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } @@ -11,7 +11,7 @@ "mfa": { "data": { "description": "Your account has 2-step verification enabled. Please enter the verification code Verisure sends to you.", - "code": "Verification Code" + "code": "Verification code" } }, "installation": { @@ -37,7 +37,7 @@ "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "unknown_mfa": "Unknown error occurred during MFA set up" + "unknown_mfa": "Unknown error occurred during MFA setup" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", diff --git a/homeassistant/components/versasense/sensor.py b/homeassistant/components/versasense/sensor.py index 4c861bf5787..3956bd21fea 100644 --- a/homeassistant/components/versasense/sensor.py +++ b/homeassistant/components/versasense/sensor.py @@ -86,7 +86,7 @@ class VSensor(SensorEntity): return self._unit @property - def available(self): + def available(self) -> bool: """Return if the sensor is available.""" return self._available diff --git a/homeassistant/components/versasense/switch.py b/homeassistant/components/versasense/switch.py index 10bca79e536..828dbf6d9af 100644 --- a/homeassistant/components/versasense/switch.py +++ b/homeassistant/components/versasense/switch.py @@ -84,7 +84,7 @@ class VActuator(SwitchEntity): return self._is_on @property - def available(self): + def available(self) -> bool: """Return if the actuator is available.""" return self._available diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index f817c1d0714..6dda6800c62 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -9,7 +9,7 @@ from pyvesync.vesyncswitch import VeSyncWallSwitch from homeassistant.core import HomeAssistant -from .const import VeSyncHumidifierDevice +from .const import VeSyncFanDevice, VeSyncHumidifierDevice _LOGGER = logging.getLogger(__name__) @@ -58,6 +58,12 @@ def is_humidifier(device: VeSyncBaseDevice) -> bool: return isinstance(device, VeSyncHumidifierDevice) +def is_fan(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a fan.""" + + return isinstance(device, VeSyncFanDevice) + + def is_outlet(device: VeSyncBaseDevice) -> bool: """Check if the device represents an outlet.""" diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index ff55bcf2e37..08db4463e07 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -1,6 +1,12 @@ """Constants for VeSync Component.""" -from pyvesync.vesyncfan import VeSyncHumid200300S, VeSyncSuperior6000S +from pyvesync.vesyncfan import ( + VeSyncAir131, + VeSyncAirBaseV2, + VeSyncAirBypass, + VeSyncHumid200300S, + VeSyncSuperior6000S, +) DOMAIN = "vesync" VS_DISCOVERY = "vesync_discovery_{}" @@ -30,6 +36,27 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" VS_HUMIDIFIER_MODE_MANUAL = "manual" VS_HUMIDIFIER_MODE_SLEEP = "sleep" +VS_FAN_MODE_AUTO = "auto" +VS_FAN_MODE_SLEEP = "sleep" +VS_FAN_MODE_ADVANCED_SLEEP = "advancedSleep" +VS_FAN_MODE_TURBO = "turbo" +VS_FAN_MODE_PET = "pet" +VS_FAN_MODE_MANUAL = "manual" +VS_FAN_MODE_NORMAL = "normal" + +# not a full list as manual is used as speed not present +VS_FAN_MODE_PRESET_LIST_HA = [ + VS_FAN_MODE_AUTO, + VS_FAN_MODE_SLEEP, + VS_FAN_MODE_ADVANCED_SLEEP, + VS_FAN_MODE_TURBO, + VS_FAN_MODE_PET, + VS_FAN_MODE_NORMAL, +] +NIGHT_LIGHT_LEVEL_BRIGHT = "bright" +NIGHT_LIGHT_LEVEL_DIM = "dim" +NIGHT_LIGHT_LEVEL_OFF = "off" + FAN_NIGHT_LIGHT_LEVEL_DIM = "dim" FAN_NIGHT_LIGHT_LEVEL_OFF = "off" FAN_NIGHT_LIGHT_LEVEL_ON = "on" @@ -41,6 +68,10 @@ HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF = "off" VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S """Humidifier device types""" +VeSyncFanDevice = VeSyncAirBypass | VeSyncAirBypass | VeSyncAirBaseV2 | VeSyncAir131 +"""Fan device types""" + + DEV_TYPE_TO_HA = { "wifi-switch-1.3": "outlet", "ESW03-USA": "outlet", diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index daf734d50a8..d9336552744 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -11,6 +11,7 @@ from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.fan import FanEntity, FanEntityFeature 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 homeassistant.util.percentage import ( @@ -19,43 +20,27 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range +from .common import is_fan from .const import ( - DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, + VS_FAN_MODE_ADVANCED_SLEEP, + VS_FAN_MODE_AUTO, + VS_FAN_MODE_MANUAL, + VS_FAN_MODE_NORMAL, + VS_FAN_MODE_PET, + VS_FAN_MODE_PRESET_LIST_HA, + VS_FAN_MODE_SLEEP, + VS_FAN_MODE_TURBO, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) -FAN_MODE_AUTO = "auto" -FAN_MODE_SLEEP = "sleep" -FAN_MODE_PET = "pet" -FAN_MODE_TURBO = "turbo" -FAN_MODE_ADVANCED_SLEEP = "advancedSleep" -FAN_MODE_NORMAL = "normal" - - -PRESET_MODES = { - "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "Core200S": [FAN_MODE_SLEEP], - "Core300S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "EverestAir": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO], - "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], - "Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], - "SmartTowerFan": [ - FAN_MODE_ADVANCED_SLEEP, - FAN_MODE_AUTO, - FAN_MODE_TURBO, - FAN_MODE_NORMAL, - ], -} SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), "Core200S": (1, 3), @@ -97,13 +82,8 @@ def _setup_entities( coordinator: VeSyncDataCoordinator, ): """Check if device is fan and add entity.""" - entities = [ - VeSyncFanHA(dev, coordinator) - for dev in devices - if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan" - ] - async_add_entities(entities, update_before_add=True) + async_add_entities(VeSyncFanHA(dev, coordinator) for dev in devices if is_fan(dev)) class VeSyncFanHA(VeSyncBaseEntity, FanEntity): @@ -118,13 +98,6 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): _attr_name = None _attr_translation_key = "vesync" - def __init__( - self, fan: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize the VeSync fan device.""" - super().__init__(fan, coordinator) - self.smartfan = fan - @property def is_on(self) -> bool: """Return True if device is on.""" @@ -134,8 +107,8 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): def percentage(self) -> int | None: """Return the current speed.""" if ( - self.smartfan.mode == "manual" - and (current_level := self.smartfan.fan_level) is not None + self.device.mode == VS_FAN_MODE_MANUAL + and (current_level := self.device.fan_level) is not None ): return ranged_value_to_percentage( SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], current_level @@ -152,13 +125,21 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): @property def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" - return PRESET_MODES[SKU_TO_BASE_DEVICE[self.device.device_type]] + if hasattr(self.device, "modes"): + return sorted( + [ + mode + for mode in self.device.modes + if mode in VS_FAN_MODE_PRESET_LIST_HA + ] + ) + return [] @property def preset_mode(self) -> str | None: """Get the current preset mode.""" - if self.smartfan.mode in (FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO): - return self.smartfan.mode + if self.device.mode in VS_FAN_MODE_PRESET_LIST_HA: + return self.device.mode return None @property @@ -166,65 +147,73 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): """Return the state attributes of the fan.""" attr = {} - if hasattr(self.smartfan, "active_time"): - attr["active_time"] = self.smartfan.active_time + if hasattr(self.device, "active_time"): + attr["active_time"] = self.device.active_time - if hasattr(self.smartfan, "screen_status"): - attr["screen_status"] = self.smartfan.screen_status + if hasattr(self.device, "screen_status"): + attr["screen_status"] = self.device.screen_status - if hasattr(self.smartfan, "child_lock"): - attr["child_lock"] = self.smartfan.child_lock + if hasattr(self.device, "child_lock"): + attr["child_lock"] = self.device.child_lock - if hasattr(self.smartfan, "night_light"): - attr["night_light"] = self.smartfan.night_light + if hasattr(self.device, "night_light"): + attr["night_light"] = self.device.night_light - if hasattr(self.smartfan, "mode"): - attr["mode"] = self.smartfan.mode + if hasattr(self.device, "mode"): + attr["mode"] = self.device.mode return attr def set_percentage(self, percentage: int) -> None: """Set the speed of the device.""" if percentage == 0: - self.smartfan.turn_off() - return + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + elif not self.device.is_on: + success = self.device.turn_on() + if not success: + raise HomeAssistantError("An error occurred while turning on.") - if not self.smartfan.is_on: - self.smartfan.turn_on() - - self.smartfan.manual_mode() - self.smartfan.change_fan_speed( + success = self.device.manual_mode() + if not success: + raise HomeAssistantError("An error occurred while manual mode.") + success = self.device.change_fan_speed( math.ceil( percentage_to_ranged_value( SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage ) ) ) + if not success: + raise HomeAssistantError("An error occurred while changing fan speed.") self.schedule_update_ha_state() def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of device.""" - if preset_mode not in self.preset_modes: + if preset_mode not in VS_FAN_MODE_PRESET_LIST_HA: raise ValueError( f"{preset_mode} is not one of the valid preset modes: " - f"{self.preset_modes}" + f"{VS_FAN_MODE_PRESET_LIST_HA}" ) - if not self.smartfan.is_on: - self.smartfan.turn_on() + if not self.device.is_on: + self.device.turn_on() - if preset_mode == FAN_MODE_AUTO: - self.smartfan.auto_mode() - elif preset_mode == FAN_MODE_SLEEP: - self.smartfan.sleep_mode() - elif preset_mode == FAN_MODE_ADVANCED_SLEEP: - self.smartfan.advanced_sleep_mode() - elif preset_mode == FAN_MODE_PET: - self.smartfan.pet_mode() - elif preset_mode == FAN_MODE_TURBO: - self.smartfan.turbo_mode() - elif preset_mode == FAN_MODE_NORMAL: - self.smartfan.normal_mode() + if preset_mode == VS_FAN_MODE_AUTO: + success = self.device.auto_mode() + elif preset_mode == VS_FAN_MODE_SLEEP: + success = self.device.sleep_mode() + elif preset_mode == VS_FAN_MODE_ADVANCED_SLEEP: + success = self.device.advanced_sleep_mode() + elif preset_mode == VS_FAN_MODE_PET: + success = self.device.pet_mode() + elif preset_mode == VS_FAN_MODE_TURBO: + success = self.device.turbo_mode() + elif preset_mode == VS_FAN_MODE_NORMAL: + success = self.device.normal_mode() + if not success: + raise HomeAssistantError("An error occurred while setting preset mode.") self.schedule_update_ha_state() @@ -244,4 +233,7 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self.device.turn_off() + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + self.schedule_update_ha_state() diff --git a/homeassistant/components/vivotek/camera.py b/homeassistant/components/vivotek/camera.py index a8bf652e963..c044e99a82e 100644 --- a/homeassistant/components/vivotek/camera.py +++ b/homeassistant/components/vivotek/camera.py @@ -62,85 +62,58 @@ def setup_platform( ) -> None: """Set up a Vivotek IP Camera.""" creds = f"{config[CONF_USERNAME]}:{config[CONF_PASSWORD]}" - args = { - "config": config, - "cam": VivotekCamera( - host=config[CONF_IP_ADDRESS], - port=(443 if config[CONF_SSL] else 80), - verify_ssl=config[CONF_VERIFY_SSL], - usr=config[CONF_USERNAME], - pwd=config[CONF_PASSWORD], - digest_auth=config[CONF_AUTHENTICATION] == HTTP_DIGEST_AUTHENTICATION, - sec_lvl=config[CONF_SECURITY_LEVEL], - ), - "stream_source": ( - f"rtsp://{creds}@{config[CONF_IP_ADDRESS]}:554/{config[CONF_STREAM_PATH]}" - ), - } - add_entities([VivotekCam(**args)], True) + cam = VivotekCamera( + host=config[CONF_IP_ADDRESS], + port=(443 if config[CONF_SSL] else 80), + verify_ssl=config[CONF_VERIFY_SSL], + usr=config[CONF_USERNAME], + pwd=config[CONF_PASSWORD], + digest_auth=config[CONF_AUTHENTICATION] == HTTP_DIGEST_AUTHENTICATION, + sec_lvl=config[CONF_SECURITY_LEVEL], + ) + stream_source = ( + f"rtsp://{creds}@{config[CONF_IP_ADDRESS]}:554/{config[CONF_STREAM_PATH]}" + ) + add_entities([VivotekCam(config, cam, stream_source)], True) class VivotekCam(Camera): """A Vivotek IP camera.""" + _attr_brand = DEFAULT_CAMERA_BRAND _attr_supported_features = CameraEntityFeature.STREAM - def __init__(self, config, cam, stream_source): + def __init__( + self, config: ConfigType, cam: VivotekCamera, stream_source: str + ) -> None: """Initialize a Vivotek camera.""" super().__init__() self._cam = cam - self._frame_interval = 1 / config[CONF_FRAMERATE] - self._motion_detection_enabled = False - self._model_name = None - self._name = config[CONF_NAME] + self._attr_frame_interval = 1 / config[CONF_FRAMERATE] + self._attr_name = config[CONF_NAME] self._stream_source = stream_source - @property - def frame_interval(self): - """Return the interval between frames of the mjpeg stream.""" - return self._frame_interval - def camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return bytes of camera image.""" return self._cam.snapshot() - @property - def name(self): - """Return the name of this device.""" - return self._name - - async def stream_source(self): + async def stream_source(self) -> str: """Return the source of the stream.""" return self._stream_source - @property - def motion_detection_enabled(self): - """Return the camera motion detection status.""" - return self._motion_detection_enabled - def disable_motion_detection(self) -> None: """Disable motion detection in camera.""" response = self._cam.set_param(DEFAULT_EVENT_0_KEY, 0) - self._motion_detection_enabled = int(response) == 1 + self._attr_motion_detection_enabled = int(response) == 1 def enable_motion_detection(self) -> None: """Enable motion detection in camera.""" response = self._cam.set_param(DEFAULT_EVENT_0_KEY, 1) - self._motion_detection_enabled = int(response) == 1 - - @property - def brand(self): - """Return the camera brand.""" - return DEFAULT_CAMERA_BRAND - - @property - def model(self): - """Return the camera model.""" - return self._model_name + self._attr_motion_detection_enabled = int(response) == 1 def update(self) -> None: """Update entity status.""" - self._model_name = self._cam.model_name + self._attr_model = self._cam.model_name diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py index d1a481a99b1..7c8bdcf8a6e 100644 --- a/homeassistant/components/vlc/media_player.py +++ b/homeassistant/components/vlc/media_player.py @@ -70,7 +70,7 @@ class VlcDevice(MediaPlayerEntity): self._vlc = self._instance.media_player_new() self._attr_name = name - def update(self): + def update(self) -> None: """Get the latest details from the device.""" status = self._vlc.get_state() if status == vlc.State.Playing: @@ -88,8 +88,6 @@ class VlcDevice(MediaPlayerEntity): self._attr_volume_level = self._vlc.audio_get_volume() / 100 self._attr_is_volume_muted = self._vlc.audio_get_mute() == 1 - return True - def media_seek(self, position: float) -> None: """Seek the media to a specific location.""" track_length = self._vlc.get_length() / 1000 diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 5efc33ca882..17b0fe6e501 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -37,7 +37,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): coordinator = entry.runtime_data await coordinator.api.logout() - await coordinator.api.close() return unload_ok diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index b69078b8ce6..c330a93a1a8 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -48,7 +48,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, await api.login() finally: await api.logout() - await api.close() return {"title": data[CONF_HOST]} diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 846d4b042c0..57d39151160 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -117,32 +117,29 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): async def _async_update_data(self) -> UpdateCoordinatorDataType: """Update router data.""" _LOGGER.debug("Polling Vodafone Station host: %s", self._host) + try: - try: - await self.api.login() - raw_data_devices = await self.api.get_devices_data() - data_sensors = await self.api.get_sensor_data() - await self.api.logout() - except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="cannot_authenticate", - translation_placeholders={"error": repr(err)}, - ) from err - except ( - exceptions.CannotConnect, - exceptions.AlreadyLogged, - exceptions.GenericLoginError, - JSONDecodeError, - ) as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"error": repr(err)}, - ) from err - except (ConfigEntryAuthFailed, UpdateFailed): - await self.api.close() - raise + await self.api.login() + raw_data_devices = await self.api.get_devices_data() + data_sensors = await self.api.get_sensor_data() + await self.api.logout() + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err + except ( + exceptions.CannotConnect, + exceptions.AlreadyLogged, + exceptions.GenericLoginError, + JSONDecodeError, + ) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": repr(err)}, + ) from err utc_point_in_time = dt_util.utcnow() data_devices = { diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 7b34d7a11ba..ac8065cabf7 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -336,7 +336,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol 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) + if not self._call_end_future.done(): + self._call_end_future.set_result(None) self.disconnect() break diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index fc8c6e00e84..9336ab0e36b 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -12,7 +12,13 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from .const import DOMAIN, UPDATE_INTERVAL from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input -PLATFORMS = [Platform.LOCK, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index c38b8967776..d978e1ec7c9 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -11,6 +11,8 @@ CODE_KEY = "code" CONF_STATION = "station" CHARGER_ADDED_DISCHARGED_ENERGY_KEY = "added_discharged_energy" CHARGER_ADDED_ENERGY_KEY = "added_energy" +CHARGER_ADDED_GREEN_ENERGY_KEY = "added_green_energy" +CHARGER_ADDED_GRID_ENERGY_KEY = "added_grid_energy" CHARGER_ADDED_RANGE_KEY = "added_range" CHARGER_CHARGING_POWER_KEY = "charging_power" CHARGER_CHARGING_SPEED_KEY = "charging_speed" @@ -38,6 +40,9 @@ CHARGER_STATE_OF_CHARGE_KEY = "state_of_charge" CHARGER_STATUS_ID_KEY = "status_id" CHARGER_STATUS_DESCRIPTION_KEY = "status_description" CHARGER_CONNECTIONS = "connections" +CHARGER_ECO_SMART_KEY = "ecosmart" +CHARGER_ECO_SMART_STATUS_KEY = "enabled" +CHARGER_ECO_SMART_MODE_KEY = "mode" class ChargerStatus(StrEnum): @@ -61,3 +66,11 @@ class ChargerStatus(StrEnum): WAITING_MID_SAFETY = "Waiting MID safety margin exceeded" WAITING_IN_QUEUE_ECO_SMART = "Waiting in queue by Eco-Smart" UNKNOWN = "Unknown" + + +class EcoSmartMode(StrEnum): + """Charger Eco mode select options.""" + + OFF = "off" + ECO_MODE = "eco_mode" + FULL_SOLAR = "full_solar" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 4f20f5c406d..60f062e57cc 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -19,6 +19,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CHARGER_CURRENCY_KEY, CHARGER_DATA_KEY, + CHARGER_ECO_SMART_KEY, + CHARGER_ECO_SMART_MODE_KEY, + CHARGER_ECO_SMART_STATUS_KEY, CHARGER_ENERGY_PRICE_KEY, CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, @@ -33,6 +36,7 @@ from .const import ( DOMAIN, UPDATE_INTERVAL, ChargerStatus, + EcoSmartMode, ) _LOGGER = logging.getLogger(__name__) @@ -160,6 +164,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN ) + + # Set current solar charging mode + eco_smart_enabled = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ + CHARGER_ECO_SMART_STATUS_KEY + ] + eco_smart_mode = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ + CHARGER_ECO_SMART_MODE_KEY + ] + if eco_smart_enabled is False: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF + elif eco_smart_mode == 0: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE + elif eco_smart_mode == 1: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR + return data async def _async_update_data(self) -> dict[str, Any]: @@ -241,6 +260,23 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): await self.hass.async_add_executor_job(self._pause_charger, pause) await self.async_request_refresh() + @_require_authentication + def _set_eco_smart(self, option: str) -> None: + """Set wallbox solar charging mode.""" + + if option == EcoSmartMode.ECO_MODE: + self._wallbox.enableEcoSmart(self._station, 0) + elif option == EcoSmartMode.FULL_SOLAR: + self._wallbox.enableEcoSmart(self._station, 1) + else: + self._wallbox.disableEcoSmart(self._station) + + async def async_set_eco_smart(self, option: str) -> None: + """Set wallbox solar charging mode.""" + + await self.hass.async_add_executor_job(self._set_eco_smart, option) + await self.async_request_refresh() + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/wallbox/icons.json b/homeassistant/components/wallbox/icons.json index 359e05cb441..d4495939d6d 100644 --- a/homeassistant/components/wallbox/icons.json +++ b/homeassistant/components/wallbox/icons.json @@ -1,5 +1,10 @@ { "entity": { + "select": { + "ecosmart": { + "default": "mdi:solar-power" + } + }, "sensor": { "charging_speed": { "default": "mdi:speedometer" diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py new file mode 100644 index 00000000000..7ad7a135bc8 --- /dev/null +++ b/homeassistant/components/wallbox/select.py @@ -0,0 +1,105 @@ +"""Home Assistant component for accessing the Wallbox Portal API. The switch component creates a switch entity.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from requests import HTTPError + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CHARGER_DATA_KEY, + CHARGER_ECO_SMART_KEY, + CHARGER_FEATURES_KEY, + CHARGER_PLAN_KEY, + CHARGER_POWER_BOOST_KEY, + CHARGER_SERIAL_NUMBER_KEY, + DOMAIN, + EcoSmartMode, +) +from .coordinator import WallboxCoordinator +from .entity import WallboxEntity + + +@dataclass(frozen=True, kw_only=True) +class WallboxSelectEntityDescription(SelectEntityDescription): + """Describes Wallbox select entity.""" + + current_option_fn: Callable[[WallboxCoordinator], str | None] + select_option_fn: Callable[[WallboxCoordinator, str], Awaitable[None]] + supported_fn: Callable[[WallboxCoordinator], bool] + + +SELECT_TYPES: dict[str, WallboxSelectEntityDescription] = { + CHARGER_ECO_SMART_KEY: WallboxSelectEntityDescription( + key=CHARGER_ECO_SMART_KEY, + translation_key=CHARGER_ECO_SMART_KEY, + options=[ + EcoSmartMode.OFF, + EcoSmartMode.ECO_MODE, + EcoSmartMode.FULL_SOLAR, + ], + select_option_fn=lambda coordinator, mode: coordinator.async_set_eco_smart( + mode + ), + current_option_fn=lambda coordinator: coordinator.data[CHARGER_ECO_SMART_KEY], + supported_fn=lambda coordinator: coordinator.data[CHARGER_DATA_KEY][ + CHARGER_PLAN_KEY + ][CHARGER_FEATURES_KEY].count(CHARGER_POWER_BOOST_KEY), + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create wallbox select entities in HASS.""" + coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + WallboxSelect(coordinator, description) + for ent in coordinator.data + if ( + (description := SELECT_TYPES.get(ent)) + and description.supported_fn(coordinator) + ) + ) + + +class WallboxSelect(WallboxEntity, SelectEntity): + """Representation of the Wallbox portal.""" + + entity_description: WallboxSelectEntityDescription + + def __init__( + self, + coordinator: WallboxCoordinator, + description: WallboxSelectEntityDescription, + ) -> None: + """Initialize a Wallbox select entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + + @property + def current_option(self) -> str | None: + """Return an option.""" + return self.entity_description.current_option_fn(self.coordinator) + + async def async_select_option(self, option: str) -> None: + """Handle the selection of an option.""" + try: + await self.entity_description.select_option_fn(self.coordinator, option) + except (ConnectionError, HTTPError) as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 78b26520bec..4b0ec8175e3 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -27,6 +27,8 @@ from homeassistant.helpers.typing import StateType from .const import ( CHARGER_ADDED_DISCHARGED_ENERGY_KEY, CHARGER_ADDED_ENERGY_KEY, + CHARGER_ADDED_GREEN_ENERGY_KEY, + CHARGER_ADDED_GRID_ENERGY_KEY, CHARGER_ADDED_RANGE_KEY, CHARGER_CHARGING_POWER_KEY, CHARGER_CHARGING_SPEED_KEY, @@ -99,6 +101,22 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + CHARGER_ADDED_GREEN_ENERGY_KEY: WallboxSensorEntityDescription( + key=CHARGER_ADDED_GREEN_ENERGY_KEY, + translation_key=CHARGER_ADDED_GREEN_ENERGY_KEY, + precision=2, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + CHARGER_ADDED_GRID_ENERGY_KEY: WallboxSensorEntityDescription( + key=CHARGER_ADDED_GRID_ENERGY_KEY, + translation_key=CHARGER_ADDED_GRID_ENERGY_KEY, + precision=2, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), CHARGER_ADDED_DISCHARGED_ENERGY_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_DISCHARGED_ENERGY_KEY, translation_key=CHARGER_ADDED_DISCHARGED_ENERGY_KEY, diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index f4378b328d8..68602a960c2 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -59,6 +59,12 @@ "added_energy": { "name": "Added energy" }, + "added_green_energy": { + "name": "Added green energy" + }, + "added_grid_energy": { + "name": "Added grid energy" + }, "added_discharged_energy": { "name": "Discharged energy" }, @@ -91,6 +97,21 @@ "pause_resume": { "name": "Pause/resume" } + }, + "select": { + "ecosmart": { + "name": "Solar charging", + "state": { + "off": "[%key:common::state::off%]", + "eco_mode": "Eco mode", + "full_solar": "Full solar" + } + } + } + }, + "exceptions": { + "api_failed": { + "message": "Error communicating with Wallbox API" } } } diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 1e03ad88cc8..9153520e703 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from . import DOMAIN as WF_DOMAIN, UPDATE_TOPIC, WaterFurnaceData +from . import DOMAIN, UPDATE_TOPIC, WaterFurnaceData SENSORS = [ SensorEntityDescription(name="Furnace Mode", key="mode", icon="mdi:gauge"), @@ -104,7 +104,7 @@ def setup_platform( if discovery_info is None: return - client = hass.data[WF_DOMAIN] + client = hass.data[DOMAIN] add_entities(WaterFurnaceSensor(client, description) for description in SENSORS) diff --git a/homeassistant/components/weatherflow/icons.json b/homeassistant/components/weatherflow/icons.json index 71a8b48415d..e0d2459b072 100644 --- a/homeassistant/components/weatherflow/icons.json +++ b/homeassistant/components/weatherflow/icons.json @@ -11,10 +11,32 @@ "default": "mdi:weather-rainy" }, "wind_direction": { - "default": "mdi:compass-outline" + "default": "mdi:compass-outline", + "range": { + "0": "mdi:arrow-up", + "22.5": "mdi:arrow-top-right", + "67.5": "mdi:arrow-right", + "112.5": "mdi:arrow-bottom-right", + "157.5": "mdi:arrow-down", + "202.5": "mdi:arrow-bottom-left", + "247.5": "mdi:arrow-left", + "292.5": "mdi:arrow-top-left", + "337.5": "mdi:arrow-up" + } }, "wind_direction_average": { - "default": "mdi:compass-outline" + "default": "mdi:compass-outline", + "range": { + "0": "mdi:arrow-up", + "22.5": "mdi:arrow-top-right", + "67.5": "mdi:arrow-right", + "112.5": "mdi:arrow-bottom-right", + "157.5": "mdi:arrow-down", + "202.5": "mdi:arrow-bottom-left", + "247.5": "mdi:arrow-left", + "292.5": "mdi:arrow-top-left", + "337.5": "mdi:arrow-up" + } } } } diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index ddcdd4f1cf8..9c371a8399d 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -300,7 +300,9 @@ async def handle_call_service( translation_placeholders=err.translation_placeholders, ) except HomeAssistantError as err: - connection.logger.exception("Unexpected exception") + connection.logger.error( + "Error during service call to %s.%s: %s", msg["domain"], msg["service"], err + ) connection.send_error( msg["id"], const.ERR_HOME_ASSISTANT_ERROR, diff --git a/homeassistant/components/wemo/device_trigger.py b/homeassistant/components/wemo/device_trigger.py index 560c95523cd..353b0470476 100644 --- a/homeassistant/components/wemo/device_trigger.py +++ b/homeassistant/components/wemo/device_trigger.py @@ -12,7 +12,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN as WEMO_DOMAIN, WEMO_SUBSCRIPTION_EVENT +from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT from .coordinator import async_get_coordinator TRIGGER_TYPES = {EVENT_TYPE_LONG_PRESS} @@ -32,7 +32,7 @@ async def async_get_triggers( wemo_trigger = { # Required fields of TRIGGER_BASE_SCHEMA CONF_PLATFORM: "device", - CONF_DOMAIN: WEMO_DOMAIN, + CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, } diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 838073be84a..6d032a0a7b6 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import async_wemo_dispatcher_connect -from .const import DOMAIN as WEMO_DOMAIN +from .const import DOMAIN from .coordinator import DeviceCoordinator from .entity import WemoBinaryStateEntity, WemoEntity @@ -110,7 +110,7 @@ class WemoLight(WemoEntity, LightEntity): """Return the device info.""" return DeviceInfo( connections={(CONNECTION_ZIGBEE, self._unique_id)}, - identifiers={(WEMO_DOMAIN, self._unique_id)}, + identifiers={(DOMAIN, self._unique_id)}, manufacturer="Belkin", model=self._model_name, name=self.name, diff --git a/homeassistant/components/whois/const.py b/homeassistant/components/whois/const.py index f196053f48d..0b1d1717474 100644 --- a/homeassistant/components/whois/const.py +++ b/homeassistant/components/whois/const.py @@ -19,3 +19,31 @@ ATTR_EXPIRES = "expires" ATTR_NAME_SERVERS = "name_servers" ATTR_REGISTRAR = "registrar" ATTR_UPDATED = "updated" + +# Mapping of ICANN status codes to Home Assistant status types. +# From https://www.icann.org/resources/pages/epp-status-codes-2014-06-16-en +STATUS_TYPES = { + "addPeriod": "add_period", + "autoRenewPeriod": "auto_renew_period", + "inactive": "inactive", + "active": "active", + "pendingCreate": "pending_create", + "pendingRenew": "pending_renew", + "pendingRestore": "pending_restore", + "pendingTransfer": "pending_transfer", + "pendingUpdate": "pending_update", + "redemptionPeriod": "redemption_period", + "renewPeriod": "renew_period", + "serverDeleteProhibited": "server_delete_prohibited", + "serverHold": "server_hold", + "serverRenewProhibited": "server_renew_prohibited", + "serverTransferProhibited": "server_transfer_prohibited", + "serverUpdateProhibited": "server_update_prohibited", + "transferPeriod": "transfer_period", + "clientDeleteProhibited": "client_delete_prohibited", + "clientHold": "client_hold", + "clientRenewProhibited": "client_renew_prohibited", + "clientTransferProhibited": "client_transfer_prohibited", + "clientUpdateProhibited": "client_update_prohibited", + "ok": "ok", +} diff --git a/homeassistant/components/whois/icons.json b/homeassistant/components/whois/icons.json index 459ae252138..5ce1fb9717b 100644 --- a/homeassistant/components/whois/icons.json +++ b/homeassistant/components/whois/icons.json @@ -18,6 +18,9 @@ }, "reseller": { "default": "mdi:store" + }, + "status": { + "default": "mdi:check-circle" } } } diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 8098e052575..474ac366be2 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -25,7 +25,14 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import dt as dt_util -from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN +from .const import ( + ATTR_EXPIRES, + ATTR_NAME_SERVERS, + ATTR_REGISTRAR, + ATTR_UPDATED, + DOMAIN, + STATUS_TYPES, +) @dataclass(frozen=True, kw_only=True) @@ -58,6 +65,24 @@ def _ensure_timezone(timestamp: datetime | None) -> datetime | None: return timestamp +def _get_status_type(status: str | None) -> str | None: + """Get the status type from the status string. + + Returns the status type in snake_case, so it can be used as a key for the translations. + E.g: "clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited" -> "client_delete_prohibited". + """ + if status is None: + return None + + # If the status is not in the STATUS_TYPES, return the status as is. + for icann_status, hass_status in STATUS_TYPES.items(): + if icann_status in status: + return hass_status + + # If the status is not in the STATUS_TYPES, return None. + return None + + SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( WhoisSensorEntityDescription( key="admin", @@ -121,6 +146,15 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda domain: getattr(domain, "reseller", None), ), + WhoisSensorEntityDescription( + key="status", + translation_key="status", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=list(STATUS_TYPES.values()), + entity_registry_enabled_default=False, + value_fn=lambda domain: _get_status_type(domain.status), + ), ) diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json index 3b0f9dfd4d1..b236bb06208 100644 --- a/homeassistant/components/whois/strings.json +++ b/homeassistant/components/whois/strings.json @@ -47,6 +47,34 @@ }, "reseller": { "name": "Reseller" + }, + "status": { + "name": "Status", + "state": { + "add_period": "Add period", + "auto_renew_period": "Auto renew period", + "inactive": "Inactive", + "ok": "Active", + "active": "Active", + "pending_create": "Pending create", + "pending_renew": "Pending renew", + "pending_restore": "Pending restore", + "pending_transfer": "Pending transfer", + "pending_update": "Pending update", + "redemption_period": "Redemption period", + "renew_period": "Renew period", + "server_delete_prohibited": "Server delete prohibited", + "server_hold": "Server hold", + "server_renew_prohibited": "Server renew prohibited", + "server_transfer_prohibited": "Server transfer prohibited", + "server_update_prohibited": "Server update prohibited", + "transfer_period": "Transfer period", + "client_delete_prohibited": "Client delete prohibited", + "client_hold": "Client hold", + "client_renew_prohibited": "Client renew prohibited", + "client_transfer_prohibited": "Client transfer prohibited", + "client_update_prohibited": "Client update prohibited" + } } } } diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index 93fdb7cce1c..abb6dd11235 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -44,7 +44,7 @@ class BoolEntity(WiffiEntity, BinarySensorEntity): self.reset_expiration_date() @property - def available(self): + def available(self) -> bool: """Return true if value is valid.""" return self._attr_is_on is not None diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index 9afcc719c9b..f28c68dc31c 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -86,7 +86,7 @@ class NumberEntity(WiffiEntity, SensorEntity): self.reset_expiration_date() @property - def available(self): + def available(self) -> bool: """Return true if value is valid.""" return self._attr_native_value is not None @@ -116,7 +116,7 @@ class StringEntity(WiffiEntity, SensorEntity): self.reset_expiration_date() @property - def available(self): + def available(self) -> bool: """Return true if value is valid.""" return self._attr_native_value is not None diff --git a/homeassistant/components/withings/application_credentials.py b/homeassistant/components/withings/application_credentials.py index ce96ed782dd..0939f9c5b82 100644 --- a/homeassistant/components/withings/application_credentials.py +++ b/homeassistant/components/withings/application_credentials.py @@ -75,3 +75,11 @@ class WithingsLocalOAuth2Implementation(AuthImplementation): } ) return {**token, **new_token} + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "developer_dashboard_url": "https://developer.withings.com/dashboard/welcome", + "redirect_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 746fa244c8e..14c7bf640e9 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "To be able to login to Withings we require a client ID and secret. To acquire them, please follow the following steps.\n\n1. Go to the [Withings Developer Dashboard]({developer_dashboard_url}) and be sure to select the Public Cloud.\n1. Log in with your Withings account.\n1. Select **Create an application**.\n1. Select the checkbox for **Public API integration**.\n1. Select **Development** as target environment.\n1. Fill in an application name and description of your choice.\n1. Fill in `{redirect_url}` for the registered URL. Make sure that you don't press the button to test it.\n1. Fill in the client ID and secret that are now available." + }, "config": { "step": { "pick_implementation": { @@ -7,6 +10,9 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Withings integration needs to re-authenticate your account" + }, + "oauth_discovery": { + "description": "Home Assistant has found a Withings device on your network. Be aware that the setup of Withings is more complicated than many other integrations. Press **Submit** to continue setting up Withings." } }, "error": { diff --git a/homeassistant/components/wmspro/__init__.py b/homeassistant/components/wmspro/__init__.py index 37bf1495a56..ebfdf5b8b34 100644 --- a/homeassistant/components/wmspro/__init__.py +++ b/homeassistant/components/wmspro/__init__.py @@ -15,7 +15,12 @@ from homeassistant.helpers.typing import UNDEFINED from .const import DOMAIN, MANUFACTURER -PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.COVER, + Platform.LIGHT, + Platform.SCENE, +] type WebControlProConfigEntry = ConfigEntry[WebControlPro] diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py new file mode 100644 index 00000000000..f1ab0489b86 --- /dev/null +++ b/homeassistant/components/wmspro/button.py @@ -0,0 +1,40 @@ +"""Identify support for WMS WebControl pro.""" + +from __future__ import annotations + +from wmspro.const import WMS_WebControl_pro_API_actionDescription + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WebControlProConfigEntry +from .entity import WebControlProGenericEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WebControlProConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the WMS based identify buttons from a config entry.""" + hub = config_entry.runtime_data + + entities: list[WebControlProGenericEntity] = [ + WebControlProIdentifyButton(config_entry.entry_id, dest) + for dest in hub.dests.values() + if dest.action(WMS_WebControl_pro_API_actionDescription.Identify) + ] + + async_add_entities(entities) + + +class WebControlProIdentifyButton(WebControlProGenericEntity, ButtonEntity): + """Representation of a WMS based identify button.""" + + _attr_device_class = ButtonDeviceClass.IDENTIFY + + async def async_press(self) -> None: + """Handle the button press.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.Identify) + await action() diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 0c25c1b277f..0d9ccb8547d 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -47,6 +47,7 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): """Base representation of a WMS based cover.""" _drive_action_desc: WMS_WebControl_pro_API_actionDescription + _attr_name = None @property def current_cover_position(self) -> int | None: diff --git a/homeassistant/components/wmspro/entity.py b/homeassistant/components/wmspro/entity.py index 0bbbc69a294..758a89b7ed8 100644 --- a/homeassistant/components/wmspro/entity.py +++ b/homeassistant/components/wmspro/entity.py @@ -15,7 +15,6 @@ class WebControlProGenericEntity(Entity): _attr_attribution = ATTRIBUTION _attr_has_entity_name = True - _attr_name = None def __init__(self, config_entry_id: str, dest: Destination) -> None: """Initialize the entity with destination channel.""" diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index c1aeb230cab..d828c8a26e8 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -44,6 +44,7 @@ class WebControlProLight(WebControlProGenericEntity, LightEntity): """Representation of a WMS based light.""" _attr_color_mode = ColorMode.ONOFF + _attr_name = None _attr_supported_color_modes = {ColorMode.ONOFF} @property diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 6b878db8159..a48e19e59b2 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -94,21 +94,59 @@ def _get_obj_holidays( language=language, categories=set_categories, ) + + supported_languages = obj_holidays.supported_languages + default_language = obj_holidays.default_language + + if default_language and not language: + # If no language is set, use the default language + LOGGER.debug("Changing language from None to %s", default_language) + return country_holidays( # Return default if no language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + if ( - (supported_languages := obj_holidays.supported_languages) + default_language and language + and language not in supported_languages and language.startswith("en") ): + # If language does not match supported languages, use the first English variant + if default_language.startswith("en"): + LOGGER.debug("Changing language from %s to %s", language, default_language) + return country_holidays( # Return default English if default language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) for lang in supported_languages: if lang.startswith("en"): - obj_holidays = country_holidays( + LOGGER.debug("Changing language from %s to %s", language, lang) + return country_holidays( country, subdiv=province, years=year, language=lang, categories=set_categories, ) - LOGGER.debug("Changing language from %s to %s", language, lang) + + if default_language and language and language not in supported_languages: + # If language does not match supported languages, use the default language + LOGGER.debug("Changing language from %s to %s", language, default_language) + return country_holidays( # Return default English if default language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + return obj_holidays diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index b0b1e9fcc02..7a8a8181a9f 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -67,8 +67,7 @@ def add_province_and_language_to_schema( _country = country_holidays(country=country) if country_default_language := (_country.default_language): - selectable_languages = _country.supported_languages - new_selectable_languages = list(selectable_languages) + new_selectable_languages = list(_country.supported_languages) language_schema = { vol.Optional( CONF_LANGUAGE, default=country_default_language @@ -154,19 +153,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: years=year, language=language, ) - if ( - (supported_languages := obj_holidays.supported_languages) - and language - and language.startswith("en") - ): - for lang in supported_languages: - if lang.startswith("en"): - obj_holidays = country_holidays( - country, - subdiv=province, - years=year, - language=lang, - ) + else: obj_holidays = HolidayBase(years=year) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 7a03133dd86..9091dd131dd 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.73"] + "requirements": ["holidays==0.74"] } diff --git a/homeassistant/components/wsdot/manifest.json b/homeassistant/components/wsdot/manifest.json index 9b7746eea74..7956897b982 100644 --- a/homeassistant/components/wsdot/manifest.json +++ b/homeassistant/components/wsdot/manifest.json @@ -4,5 +4,7 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/wsdot", "iot_class": "cloud_polling", - "quality_scale": "legacy" + "loggers": ["wsdot"], + "quality_scale": "legacy", + "requirements": ["wsdot==0.0.1"] } diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 8ae93c809f2..ce1f775eb03 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -2,44 +2,32 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone -from http import HTTPStatus +from datetime import timedelta import logging -import re from typing import Any -import requests import voluptuous as vol +from wsdot import TravelTime, WsdotTravelError, WsdotTravelTimes from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) -from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_ID, CONF_NAME, UnitOfTime +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, UnitOfTime 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.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ATTR_ACCESS_CODE = "AccessCode" -ATTR_AVG_TIME = "AverageTime" -ATTR_CURRENT_TIME = "CurrentTime" -ATTR_DESCRIPTION = "Description" -ATTR_TIME_UPDATED = "TimeUpdated" -ATTR_TRAVEL_TIME_ID = "TravelTimeID" - ATTRIBUTION = "Data provided by WSDOT" CONF_TRAVEL_TIMES = "travel_time" ICON = "mdi:car" - -RESOURCE = ( - "http://www.wsdot.wa.gov/Traffic/api/TravelTimes/" - "TravelTimesREST.svc/GetTravelTimeAsJson" -) +DOMAIN = "wsdot" SCAN_INTERVAL = timedelta(minutes=3) @@ -53,7 +41,7 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, @@ -61,12 +49,14 @@ def setup_platform( ) -> None: """Set up the WSDOT sensor.""" sensors = [] + session = async_get_clientsession(hass) + api_key = config[CONF_API_KEY] + wsdot_travel = WsdotTravelTimes(api_key=api_key, session=session) for travel_time in config[CONF_TRAVEL_TIMES]: name = travel_time.get(CONF_NAME) or travel_time.get(CONF_ID) + travel_time_id = int(travel_time[CONF_ID]) sensors.append( - WashingtonStateTravelTimeSensor( - name, config.get(CONF_API_KEY), travel_time.get(CONF_ID) - ) + WashingtonStateTravelTimeSensor(name, wsdot_travel, travel_time_id) ) add_entities(sensors, True) @@ -82,20 +72,18 @@ class WashingtonStateTransportSensor(SensorEntity): _attr_icon = ICON - def __init__(self, name, access_code): + def __init__(self, name: str) -> None: """Initialize the sensor.""" - self._data = {} - self._access_code = access_code self._name = name - self._state = None + self._state: int | None = None @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._state @@ -106,50 +94,28 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): _attr_attribution = ATTRIBUTION _attr_native_unit_of_measurement = UnitOfTime.MINUTES - def __init__(self, name, access_code, travel_time_id): + def __init__( + self, name: str, wsdot_travel: WsdotTravelTimes, travel_time_id: int + ) -> None: """Construct a travel time sensor.""" + super().__init__(name) + self._data: TravelTime | None = None self._travel_time_id = travel_time_id - WashingtonStateTransportSensor.__init__(self, name, access_code) + self._wsdot_travel = wsdot_travel - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data from WSDOT.""" - params = { - ATTR_ACCESS_CODE: self._access_code, - ATTR_TRAVEL_TIME_ID: self._travel_time_id, - } - - response = requests.get(RESOURCE, params, timeout=10) - if response.status_code != HTTPStatus.OK: + try: + travel_time = await self._wsdot_travel.get_travel_time(self._travel_time_id) + except WsdotTravelError: _LOGGER.warning("Invalid response from WSDOT API") else: - self._data = response.json() - self._state = self._data.get(ATTR_CURRENT_TIME) + self._data = travel_time + self._state = travel_time.CurrentTime @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return other details about the sensor state.""" if self._data is not None: - attrs = {} - for key in ( - ATTR_AVG_TIME, - ATTR_NAME, - ATTR_DESCRIPTION, - ATTR_TRAVEL_TIME_ID, - ): - attrs[key] = self._data.get(key) - attrs[ATTR_TIME_UPDATED] = _parse_wsdot_timestamp( - self._data.get(ATTR_TIME_UPDATED) - ) - return attrs + return self._data.model_dump() return None - - -def _parse_wsdot_timestamp(timestamp): - """Convert WSDOT timestamp to datetime.""" - if not timestamp: - return None - # ex: Date(1485040200000-0800) - milliseconds, tzone = re.search(r"Date\((\d+)([+-]\d\d)\d\d\)", timestamp).groups() - return datetime.fromtimestamp( - int(milliseconds) / 1000, tz=timezone(timedelta(hours=int(tzone))) - ) diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 47cc823ad7f..b7a6d7ba935 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -1,6 +1,9 @@ """Support for Xiaomi aqara binary sensors.""" import logging +from typing import Any + +from xiaomi_gateway import XiaomiGateway from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -137,23 +140,20 @@ async def async_setup_entry( class XiaomiBinarySensor(XiaomiDevice, BinarySensorEntity): """Representation of a base XiaomiBinarySensor.""" - def __init__(self, device, name, xiaomi_hub, data_key, device_class, config_entry): + def __init__( + self, + device: dict[str, Any], + name: str, + xiaomi_hub: XiaomiGateway, + data_key: str, + device_class: BinarySensorDeviceClass | None, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiSmokeSensor.""" self._data_key = data_key - self._device_class = device_class - self._density = 0 + self._attr_device_class = device_class super().__init__(device, name, xiaomi_hub, config_entry) - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of binary sensor.""" - return self._device_class - def update(self) -> None: """Update the sensor state.""" _LOGGER.debug("Updating xiaomi sensor (%s) by polling", self._sid) @@ -163,11 +163,21 @@ class XiaomiBinarySensor(XiaomiDevice, BinarySensorEntity): class XiaomiNatgasSensor(XiaomiBinarySensor): """Representation of a XiaomiNatgasSensor.""" - def __init__(self, device, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiSmokeSensor.""" self._density = None super().__init__( - device, "Natgas Sensor", xiaomi_hub, "alarm", "gas", config_entry + device, + "Natgas Sensor", + xiaomi_hub, + "alarm", + BinarySensorDeviceClass.GAS, + config_entry, ) @property @@ -180,7 +190,7 @@ class XiaomiNatgasSensor(XiaomiBinarySensor): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - self._state = False + self._attr_is_on = False def parse_data(self, data, raw_data): """Parse data sent by gateway.""" @@ -192,13 +202,13 @@ class XiaomiNatgasSensor(XiaomiBinarySensor): return False if value in ("1", "2"): - if self._state: + if self._attr_is_on: return False - self._state = True + self._attr_is_on = True return True if value == "0": - if self._state: - self._state = False + if self._attr_is_on: + self._attr_is_on = False return True return False @@ -208,7 +218,13 @@ class XiaomiNatgasSensor(XiaomiBinarySensor): class XiaomiMotionSensor(XiaomiBinarySensor): """Representation of a XiaomiMotionSensor.""" - def __init__(self, device, hass, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + hass: HomeAssistant, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiMotionSensor.""" self._hass = hass self._no_motion_since = 0 @@ -218,7 +234,12 @@ class XiaomiMotionSensor(XiaomiBinarySensor): else: data_key = "motion_status" super().__init__( - device, "Motion Sensor", xiaomi_hub, data_key, "motion", config_entry + device, + "Motion Sensor", + xiaomi_hub, + data_key, + BinarySensorDeviceClass.MOTION, + config_entry, ) @property @@ -232,13 +253,13 @@ class XiaomiMotionSensor(XiaomiBinarySensor): def _async_set_no_motion(self, now): """Set state to False.""" self._unsub_set_no_motion = None - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - self._state = False + self._attr_is_on = False def parse_data(self, data, raw_data): """Parse data sent by gateway. @@ -274,7 +295,7 @@ class XiaomiMotionSensor(XiaomiBinarySensor): if NO_MOTION in data: self._no_motion_since = data[NO_MOTION] - self._state = False + self._attr_is_on = False return True value = data.get(self._data_key) @@ -295,9 +316,9 @@ class XiaomiMotionSensor(XiaomiBinarySensor): ) self._no_motion_since = 0 - if self._state: + if self._attr_is_on: return False - self._state = True + self._attr_is_on = True return True return False @@ -306,7 +327,12 @@ class XiaomiMotionSensor(XiaomiBinarySensor): class XiaomiDoorSensor(XiaomiBinarySensor, RestoreEntity): """Representation of a XiaomiDoorSensor.""" - def __init__(self, device, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiDoorSensor.""" self._open_since = 0 if "proto" not in device or int(device["proto"][0:1]) == 1: @@ -335,7 +361,7 @@ class XiaomiDoorSensor(XiaomiBinarySensor, RestoreEntity): if (state := await self.async_get_last_state()) is None: return - self._state = state.state == "on" + self._attr_is_on = state.state == "on" def parse_data(self, data, raw_data): """Parse data sent by gateway.""" @@ -350,14 +376,14 @@ class XiaomiDoorSensor(XiaomiBinarySensor, RestoreEntity): if value == "open": self._attr_should_poll = True - if self._state: + if self._attr_is_on: return False - self._state = True + self._attr_is_on = True return True if value == "close": self._open_since = 0 - if self._state: - self._state = False + if self._attr_is_on: + self._attr_is_on = False return True return False @@ -367,7 +393,12 @@ class XiaomiDoorSensor(XiaomiBinarySensor, RestoreEntity): class XiaomiWaterLeakSensor(XiaomiBinarySensor): """Representation of a XiaomiWaterLeakSensor.""" - def __init__(self, device, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiWaterLeakSensor.""" if "proto" not in device or int(device["proto"][0:1]) == 1: data_key = "status" @@ -385,7 +416,7 @@ class XiaomiWaterLeakSensor(XiaomiBinarySensor): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - self._state = False + self._attr_is_on = False def parse_data(self, data, raw_data): """Parse data sent by gateway.""" @@ -397,13 +428,13 @@ class XiaomiWaterLeakSensor(XiaomiBinarySensor): if value == "leak": self._attr_should_poll = True - if self._state: + if self._attr_is_on: return False - self._state = True + self._attr_is_on = True return True if value == "no_leak": - if self._state: - self._state = False + if self._attr_is_on: + self._attr_is_on = False return True return False @@ -413,11 +444,21 @@ class XiaomiWaterLeakSensor(XiaomiBinarySensor): class XiaomiSmokeSensor(XiaomiBinarySensor): """Representation of a XiaomiSmokeSensor.""" - def __init__(self, device, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiSmokeSensor.""" self._density = 0 super().__init__( - device, "Smoke Sensor", xiaomi_hub, "alarm", "smoke", config_entry + device, + "Smoke Sensor", + xiaomi_hub, + "alarm", + BinarySensorDeviceClass.SMOKE, + config_entry, ) @property @@ -430,7 +471,7 @@ class XiaomiSmokeSensor(XiaomiBinarySensor): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - self._state = False + self._attr_is_on = False def parse_data(self, data, raw_data): """Parse data sent by gateway.""" @@ -441,13 +482,13 @@ class XiaomiSmokeSensor(XiaomiBinarySensor): return False if value in ("1", "2"): - if self._state: + if self._attr_is_on: return False - self._state = True + self._attr_is_on = True return True if value == "0": - if self._state: - self._state = False + if self._attr_is_on: + self._attr_is_on = False return True return False @@ -457,7 +498,14 @@ class XiaomiSmokeSensor(XiaomiBinarySensor): class XiaomiVibration(XiaomiBinarySensor): """Representation of a Xiaomi Vibration Sensor.""" - def __init__(self, device, name, data_key, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + name: str, + data_key: str, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiVibration.""" self._last_action = None super().__init__(device, name, xiaomi_hub, data_key, None, config_entry) @@ -472,7 +520,7 @@ class XiaomiVibration(XiaomiBinarySensor): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - self._state = False + self._attr_is_on = False def parse_data(self, data, raw_data): """Parse data sent by gateway.""" @@ -496,7 +544,15 @@ class XiaomiVibration(XiaomiBinarySensor): class XiaomiButton(XiaomiBinarySensor): """Representation of a Xiaomi Button.""" - def __init__(self, device, name, data_key, hass, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + name: str, + data_key: str, + hass: HomeAssistant, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiButton.""" self._hass = hass self._last_action = None @@ -512,7 +568,7 @@ class XiaomiButton(XiaomiBinarySensor): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - self._state = False + self._attr_is_on = False def parse_data(self, data, raw_data): """Parse data sent by gateway.""" @@ -521,10 +577,10 @@ class XiaomiButton(XiaomiBinarySensor): return False if value == "long_click_press": - self._state = True + self._attr_is_on = True click_type = "long_click_press" elif value == "long_click_release": - self._state = False + self._attr_is_on = False click_type = "hold" elif value == "click": click_type = "single" @@ -556,7 +612,13 @@ class XiaomiButton(XiaomiBinarySensor): class XiaomiCube(XiaomiBinarySensor): """Representation of a Xiaomi Cube.""" - def __init__(self, device, hass, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + hass: HomeAssistant, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the Xiaomi Cube.""" self._hass = hass self._last_action = None @@ -576,7 +638,7 @@ class XiaomiCube(XiaomiBinarySensor): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - self._state = False + self._attr_is_on = False def parse_data(self, data, raw_data): """Parse data sent by gateway.""" diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index 82d5129ac5e..ebab3344250 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -2,6 +2,8 @@ from typing import Any +from xiaomi_gateway import XiaomiGateway + from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -40,7 +42,14 @@ async def async_setup_entry( class XiaomiGenericCover(XiaomiDevice, CoverEntity): """Representation of a XiaomiGenericCover.""" - def __init__(self, device, name, data_key, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + name: str, + data_key: str, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiGenericCover.""" self._data_key = data_key self._pos = 0 diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py index 59107984ddf..3f640b67516 100644 --- a/homeassistant/components/xiaomi_aqara/entity.py +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -2,8 +2,11 @@ from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any +from xiaomi_gateway import XiaomiGateway + +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, CONF_MAC from homeassistant.core import callback from homeassistant.helpers import device_registry as dr @@ -24,9 +27,14 @@ class XiaomiDevice(Entity): _attr_should_poll = False - def __init__(self, device, device_type, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + device_type: str, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the Xiaomi device.""" - self._state = None self._is_available = True self._sid = device["sid"] self._model = device["model"] @@ -36,7 +44,7 @@ class XiaomiDevice(Entity): self._type = device_type self._write_to_hub = xiaomi_hub.write_to_hub self._get_from_hub = xiaomi_hub.get_from_hub - self._extra_state_attributes = {} + self._attr_extra_state_attributes = {} self._remove_unavailability_tracker = None self._xiaomi_hub = xiaomi_hub self.parse_data(device["data"], device["raw_data"]) @@ -51,6 +59,8 @@ class XiaomiDevice(Entity): if config_entry.data[CONF_MAC] == format_mac(self._sid): # this entity belongs to the gateway itself self._is_gateway = True + if TYPE_CHECKING: + assert config_entry.unique_id self._device_id = config_entry.unique_id else: # this entity is connected through zigbee @@ -87,6 +97,8 @@ class XiaomiDevice(Entity): model=self._model, ) else: + if TYPE_CHECKING: + assert self._gateway_id is not None device_info = DeviceInfo( connections={(dr.CONNECTION_ZIGBEE, self._device_id)}, identifiers={(DOMAIN, self._device_id)}, @@ -104,11 +116,6 @@ class XiaomiDevice(Entity): """Return True if entity is available.""" return self._is_available - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._extra_state_attributes - @callback def _async_set_unavailable(self, now): """Set state to UNAVAILABLE.""" @@ -154,11 +161,11 @@ class XiaomiDevice(Entity): max_volt = 3300 min_volt = 2800 voltage = data[voltage_key] - self._extra_state_attributes[ATTR_VOLTAGE] = round(voltage / 1000.0, 2) + self._attr_extra_state_attributes[ATTR_VOLTAGE] = round(voltage / 1000.0, 2) voltage = min(voltage, max_volt) voltage = max(voltage, min_volt) percent = ((voltage - min_volt) / (max_volt - min_volt)) * 100 - self._extra_state_attributes[ATTR_BATTERY_LEVEL] = round(percent, 1) + self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = round(percent, 1) return True def parse_data(self, data, raw_data): diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index ef1f06695f9..47b9e5a6730 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -5,6 +5,8 @@ import logging import struct from typing import Any +from xiaomi_gateway import XiaomiGateway + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, @@ -45,7 +47,13 @@ class XiaomiGatewayLight(XiaomiDevice, LightEntity): _attr_color_mode = ColorMode.HS _attr_supported_color_modes = {ColorMode.HS} - def __init__(self, device, name, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + name: str, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiGatewayLight.""" self._data_key = "rgb" self._hs = (0, 0) @@ -53,11 +61,6 @@ class XiaomiGatewayLight(XiaomiDevice, LightEntity): super().__init__(device, name, xiaomi_hub, config_entry) - @property - def is_on(self): - """Return true if it is on.""" - return self._state - def parse_data(self, data, raw_data): """Parse data sent by gateway.""" value = data.get(self._data_key) @@ -65,7 +68,7 @@ class XiaomiGatewayLight(XiaomiDevice, LightEntity): return False if value == 0: - self._state = False + self._attr_is_on = False return True rgbhexstr = f"{value:x}" @@ -84,7 +87,7 @@ class XiaomiGatewayLight(XiaomiDevice, LightEntity): self._brightness = brightness self._hs = color_util.color_RGB_to_hs(*rgb) - self._state = True + self._attr_is_on = True return True @property @@ -97,7 +100,7 @@ class XiaomiGatewayLight(XiaomiDevice, LightEntity): """Return the hs color value.""" return self._hs - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if ATTR_HS_COLOR in kwargs: self._hs = kwargs[ATTR_HS_COLOR] @@ -107,15 +110,15 @@ class XiaomiGatewayLight(XiaomiDevice, LightEntity): rgb = color_util.color_hs_to_RGB(*self._hs) rgba = (self._brightness, *rgb) - rgbhex = binascii.hexlify(struct.pack("BBBB", *rgba)).decode("ASCII") - rgbhex = int(rgbhex, 16) + rgbhex_str = binascii.hexlify(struct.pack("BBBB", *rgba)).decode("ASCII") + rgbhex = int(rgbhex_str, 16) if self._write_to_hub(self._sid, **{self._data_key: rgbhex}): - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" if self._write_to_hub(self._sid, **{self._data_key: 0}): - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index b3f4e9f4caf..86d20a7024f 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -2,7 +2,11 @@ from __future__ import annotations -from homeassistant.components.lock import LockEntity, LockState +from typing import Any + +from xiaomi_gateway import XiaomiGateway + +from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -38,25 +42,19 @@ async def async_setup_entry( class XiaomiAqaraLock(LockEntity, XiaomiDevice): """Representation of a XiaomiAqaraLock.""" - def __init__(self, device, name, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + name: str, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiAqaraLock.""" - self._changed_by = 0 + self._attr_changed_by = "0" self._verified_wrong_times = 0 super().__init__(device, name, xiaomi_hub, config_entry) - @property - def is_locked(self) -> bool | None: - """Return true if lock is locked.""" - if self._state is not None: - return self._state == LockState.LOCKED - return None - - @property - def changed_by(self) -> str: - """Last change triggered by.""" - return self._changed_by - @property def extra_state_attributes(self) -> dict[str, int]: """Return the state attributes.""" @@ -65,7 +63,7 @@ class XiaomiAqaraLock(LockEntity, XiaomiDevice): @callback def clear_unlock_state(self, _): """Clear unlock state automatically.""" - self._state = LockState.LOCKED + self._attr_is_locked = True self.async_write_ha_state() def parse_data(self, data, raw_data): @@ -76,9 +74,9 @@ class XiaomiAqaraLock(LockEntity, XiaomiDevice): for key in (FINGER_KEY, PASSWORD_KEY, CARD_KEY): if (value := data.get(key)) is not None: - self._changed_by = int(value) + self._attr_changed_by = str(int(value)) self._verified_wrong_times = 0 - self._state = LockState.UNLOCKED + self._attr_is_locked = False async_call_later( self.hass, UNLOCK_MAINTAIN_TIME, self.clear_unlock_state ) diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 59ccee5a1a8..2855bf14a3f 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -3,6 +3,9 @@ from __future__ import annotations import logging +from typing import Any + +from xiaomi_gateway import XiaomiGateway from homeassistant.components.sensor import ( SensorDeviceClass, @@ -164,7 +167,14 @@ async def async_setup_entry( class XiaomiSensor(XiaomiDevice, SensorEntity): """Representation of a XiaomiSensor.""" - def __init__(self, device, name, data_key, xiaomi_hub, config_entry): + def __init__( + self, + device: dict[str, Any], + name: str, + data_key: str, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiSensor.""" self._data_key = data_key self.entity_description = SENSOR_TYPES[data_key] @@ -206,7 +216,7 @@ class XiaomiBatterySensor(XiaomiDevice, SensorEntity): succeed = super().parse_voltage(data) if not succeed: return False - battery_level = int(self._extra_state_attributes.pop(ATTR_BATTERY_LEVEL)) + battery_level = int(self._attr_extra_state_attributes.pop(ATTR_BATTERY_LEVEL)) if battery_level <= 0 or battery_level > 100: return False self._attr_native_value = battery_level diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index 7d3abf47bd1..e9e2c92314e 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -3,6 +3,8 @@ import logging from typing import Any +from xiaomi_gateway import XiaomiGateway + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -138,13 +140,13 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchEntity): def __init__( self, - device, - name, - data_key, - supports_power_consumption, - xiaomi_hub, - config_entry, - ): + device: dict[str, Any], + name: str, + data_key: str, + supports_power_consumption: bool, + xiaomi_hub: XiaomiGateway, + config_entry: ConfigEntry, + ) -> None: """Initialize the XiaomiPlug.""" self._data_key = data_key self._in_use = None @@ -162,11 +164,6 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchEntity): return "mdi:power-plug" return "mdi:power-socket" - @property - def is_on(self): - """Return true if it is on.""" - return self._state - @property def extra_state_attributes(self): """Return the state attributes.""" @@ -184,13 +181,13 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self._write_to_hub(self._sid, **{self._data_key: "on"}): - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" if self._write_to_hub(self._sid, **{self._data_key: "off"}): - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() def parse_data(self, data, raw_data): @@ -213,9 +210,9 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchEntity): return False state = value == "on" - if self._state == state: + if self._attr_is_on == state: return False - self._state = state + self._attr_is_on = state return True def update(self) -> None: diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 3f13c7921a8..2b87da630a0 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.38.0"] + "requirements": ["xiaomi-ble==0.39.0"] } diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index d841045d235..0e28a2900bb 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -35,7 +35,6 @@ from miio import ( ) from miio.gateway.gateway import GatewayException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -47,8 +46,6 @@ from .const import ( CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE, MODEL_AIRFRESH_A1, MODEL_AIRFRESH_T2017, MODEL_FAN_1C, @@ -75,6 +72,7 @@ from .const import ( SetupException, ) from .gateway import ConnectXiaomiGateway +from .typing import XiaomiMiioConfigEntry, XiaomiMiioRuntimeData _LOGGER = logging.getLogger(__name__) @@ -125,9 +123,8 @@ MODEL_TO_CLASS_MAP = { } -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: XiaomiMiioConfigEntry) -> bool: """Set up the Xiaomi Miio components from a config entry.""" - hass.data.setdefault(DOMAIN, {}) if entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: await async_setup_gateway_entry(hass, entry) return True @@ -291,14 +288,13 @@ def _async_update_data_vacuum( async def async_create_miio_device_and_coordinator( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: XiaomiMiioConfigEntry ) -> None: """Set up a data coordinator and one miio device to service multiple entities.""" model: str = entry.data[CONF_MODEL] host = entry.data[CONF_HOST] token = entry.data[CONF_TOKEN] name = entry.title - device: MiioDevice | None = None migrate = False update_method = _async_update_data_default coordinator_class: type[DataUpdateCoordinator[Any]] = DataUpdateCoordinator @@ -323,6 +319,7 @@ async def async_create_miio_device_and_coordinator( _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + device: MiioDevice # Humidifiers if model in MODELS_HUMIDIFIER_MIOT: device = AirHumidifierMiot(host, token, lazy_discover=lazy_discover) @@ -394,16 +391,18 @@ async def async_create_miio_device_and_coordinator( # Polling interval. Will only be polled if there are subscribers. update_interval=UPDATE_INTERVAL, ) - hass.data[DOMAIN][entry.entry_id] = { - KEY_DEVICE: device, - KEY_COORDINATOR: coordinator, - } # Trigger first data fetch await coordinator.async_config_entry_first_refresh() + entry.runtime_data = XiaomiMiioRuntimeData( + device=device, device_coordinator=coordinator + ) -async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + +async def async_setup_gateway_entry( + hass: HomeAssistant, entry: XiaomiMiioConfigEntry +) -> None: """Set up the Xiaomi Gateway component from a config entry.""" host = entry.data[CONF_HOST] token = entry.data[CONF_TOKEN] @@ -461,17 +460,18 @@ async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) -> update_interval=UPDATE_INTERVAL, ) - hass.data[DOMAIN][entry.entry_id] = { - CONF_GATEWAY: gateway.gateway_device, - KEY_COORDINATOR: coordinator_dict, - } + entry.runtime_data = XiaomiMiioRuntimeData( + gateway=gateway.gateway_device, gateway_coordinators=coordinator_dict + ) await hass.config_entries.async_forward_entry_setups(entry, GATEWAY_PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) -async def async_setup_device_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_device_entry( + hass: HomeAssistant, entry: XiaomiMiioConfigEntry +) -> bool: """Set up the Xiaomi Miio device component from a config entry.""" platforms = get_platforms(entry) await async_create_miio_device_and_coordinator(hass, entry) @@ -486,20 +486,17 @@ async def async_setup_device_entry(hass: HomeAssistant, entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: XiaomiMiioConfigEntry +) -> bool: """Unload a config entry.""" platforms = get_platforms(config_entry) - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, platforms - ) - - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, platforms) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: XiaomiMiioConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 1ce37c661a2..9e52abb1c85 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -3,10 +3,14 @@ from collections.abc import Callable import logging -from miio import AirQualityMonitor, AirQualityMonitorCGDN1, DeviceException +from miio import ( + AirQualityMonitor, + AirQualityMonitorCGDN1, + Device as MiioDevice, + DeviceException, +) from homeassistant.components.air_quality import AirQualityEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -19,6 +23,7 @@ from .const import ( MODEL_AIRQUALITYMONITOR_V1, ) from .entity import XiaomiMiioEntity +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -40,12 +45,18 @@ PROP_TO_ATTR = { class AirMonitorB1(XiaomiMiioEntity, AirQualityEntity): """Air Quality class for Xiaomi cgllc.airmonitor.b1 device.""" - def __init__(self, name, device, entry, unique_id): + _attr_icon = "mdi:cloud" + + def __init__( + self, + name: str, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the entity.""" super().__init__(name, device, entry, unique_id) - self._icon = "mdi:cloud" - self._available = None self._air_quality_index = None self._carbon_dioxide = None self._carbon_dioxide_equivalent = None @@ -64,21 +75,11 @@ class AirMonitorB1(XiaomiMiioEntity, AirQualityEntity): self._total_volatile_organic_compounds = round(state.tvoc, 3) self._temperature = round(state.temperature, 2) self._humidity = round(state.humidity, 2) - self._available = True + self._attr_available = True except DeviceException as ex: - self._available = False + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def available(self): - """Return true when state is known.""" - return self._available - @property def air_quality_index(self): """Return the Air Quality Index (AQI).""" @@ -139,10 +140,10 @@ class AirMonitorS1(AirMonitorB1): self._total_volatile_organic_compounds = state.tvoc self._temperature = state.temperature self._humidity = state.humidity - self._available = True + self._attr_available = True except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) @@ -155,10 +156,10 @@ class AirMonitorV1(AirMonitorB1): state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._air_quality_index = state.aqi - self._available = True + self._attr_available = True except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) @property @@ -170,12 +171,18 @@ class AirMonitorV1(AirMonitorB1): class AirMonitorCGDN1(XiaomiMiioEntity, AirQualityEntity): """Air Quality class for cgllc.airm.cgdn1 device.""" - def __init__(self, name, device, entry, unique_id): + _attr_icon = "mdi:cloud" + + def __init__( + self, + name: str, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the entity.""" super().__init__(name, device, entry, unique_id) - self._icon = "mdi:cloud" - self._available = None self._carbon_dioxide = None self._particulate_matter_2_5 = None self._particulate_matter_10 = None @@ -188,21 +195,11 @@ class AirMonitorCGDN1(XiaomiMiioEntity, AirQualityEntity): self._carbon_dioxide = state.co2 self._particulate_matter_2_5 = round(state.pm25, 1) self._particulate_matter_10 = round(state.pm10, 1) - self._available = True + self._attr_available = True except DeviceException as ex: - self._available = False + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def available(self): - """Return true when state is known.""" - return self._available - @property def carbon_dioxide(self): """Return the CO2 (carbon dioxide) level.""" @@ -241,7 +238,7 @@ DEVICE_MAP: dict[str, dict[str, Callable]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi Air Quality from a config entry.""" diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index ecab5228f6e..435253ae8d1 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -12,12 +12,12 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_GATEWAY, DOMAIN +from .const import DOMAIN +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -28,12 +28,12 @@ XIAOMI_STATE_ARMING_VALUE = "oning" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi Gateway Alarm from a config entry.""" entities = [] - gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] + gateway = config_entry.runtime_data.gateway entity = XiaomiGatewayAlarm( gateway, f"{config_entry.title} Alarm", diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 213886691f0..205db7cd21c 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -5,23 +5,23 @@ from __future__ import annotations from collections.abc import Callable, Iterable from dataclasses import dataclass import logging +from typing import TYPE_CHECKING, Any + +from miio import Device as MiioDevice from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import VacuumCoordinatorDataAttributes from .const import ( CONF_FLOW_TYPE, - DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE, MODEL_AIRFRESH_A1, MODEL_AIRFRESH_T2017, MODEL_FAN_ZA5, @@ -33,6 +33,7 @@ from .const import ( MODELS_VACUUM_WITH_SEPARATE_MOP, ) from .entity import XiaomiCoordinatedMiioEntity +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -133,13 +134,17 @@ HUMIDIFIER_MIOT_BINARY_SENSORS = (ATTR_WATER_TANK_DETACHED,) HUMIDIFIER_MJJSQ_BINARY_SENSORS = (ATTR_NO_WATER, ATTR_WATER_TANK_DETACHED) -def _setup_vacuum_sensors(hass, config_entry, async_add_entities): +def _setup_vacuum_sensors( + hass: HomeAssistant, + config_entry: XiaomiMiioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Only vacuums with mop should have binary sensor registered.""" if config_entry.data[CONF_MODEL] not in MODELS_VACUUM_WITH_MOP: return - device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE) - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + device = config_entry.runtime_data.device + coordinator = config_entry.runtime_data.device_coordinator entities = [] sensors = VACUUM_SENSORS @@ -147,6 +152,8 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities): sensors = VACUUM_SENSORS_SEPARATE_MOP for sensor, description in sensors.items(): + if TYPE_CHECKING: + assert description.parent_key is not None parent_key_data = getattr(coordinator.data, description.parent_key) if getattr(parent_key_data, description.key, None) is None: _LOGGER.debug( @@ -170,7 +177,7 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi sensor from a config entry.""" @@ -198,10 +205,10 @@ async def async_setup_entry( continue entities.append( XiaomiGenericBinarySensor( - hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE], + config_entry.runtime_data.device, config_entry, f"{description.key}_{config_entry.unique_id}", - hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + config_entry.runtime_data.device_coordinator, description, ) ) @@ -209,12 +216,21 @@ async def async_setup_entry( async_add_entities(entities) -class XiaomiGenericBinarySensor(XiaomiCoordinatedMiioEntity, BinarySensorEntity): +class XiaomiGenericBinarySensor( + XiaomiCoordinatedMiioEntity[DataUpdateCoordinator[Any]], BinarySensorEntity +): """Representation of a Xiaomi Humidifier binary sensor.""" entity_description: XiaomiMiioBinarySensorDescription - def __init__(self, device, entry, unique_id, coordinator, description): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str, + coordinator: DataUpdateCoordinator[Any], + description: XiaomiMiioBinarySensorDescription, + ) -> None: """Initialize the entity.""" super().__init__(device, entry, unique_id, coordinator) diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index a7bcb3a12fe..58236e136cb 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -3,7 +3,9 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any +from miio import Device as MiioDevice from miio.integrations.vacuum.roborock.vacuum import Consumable from homeassistant.components.button import ( @@ -11,20 +13,14 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ( - DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE, - MODEL_AIRFRESH_A1, - MODEL_AIRFRESH_T2017, - MODELS_VACUUM, -) +from .const import MODEL_AIRFRESH_A1, MODEL_AIRFRESH_T2017, MODELS_VACUUM from .entity import XiaomiCoordinatedMiioEntity +from .typing import XiaomiMiioConfigEntry # Fans ATTR_RESET_DUST_FILTER = "reset_dust_filter" @@ -123,7 +119,7 @@ MODEL_TO_BUTTON_MAP: dict[str, tuple[str, ...]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the button from a config entry.""" @@ -135,8 +131,8 @@ async def async_setup_entry( entities = [] buttons = MODEL_TO_BUTTON_MAP[model] unique_id = config_entry.unique_id - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + device = config_entry.runtime_data.device + coordinator = config_entry.runtime_data.device_coordinator for description in BUTTON_TYPES: if description.key not in buttons: @@ -155,14 +151,23 @@ async def async_setup_entry( async_add_entities(entities) -class XiaomiGenericCoordinatedButton(XiaomiCoordinatedMiioEntity, ButtonEntity): +class XiaomiGenericCoordinatedButton( + XiaomiCoordinatedMiioEntity[DataUpdateCoordinator[Any]], ButtonEntity +): """A button implementation for Xiaomi.""" entity_description: XiaomiMiioButtonDescription _attr_device_class = ButtonDeviceClass.RESTART - def __init__(self, device, entry, unique_id, coordinator, description): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str, + coordinator: DataUpdateCoordinator[Any], + description: XiaomiMiioButtonDescription, + ) -> None: """Initialize the plug switch.""" super().__init__(device, entry, unique_id, coordinator) self.entity_description = description diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index c3ebc48d743..b8d8b028006 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -11,12 +11,7 @@ from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -40,6 +35,7 @@ from .const import ( SetupException, ) from .device import ConnectXiaomiDevice +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -116,7 +112,9 @@ class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow( + config_entry: XiaomiMiioConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 2b9cdb2ffdd..0c188f20a02 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -27,9 +27,6 @@ CONF_MANUAL = "manual" # Options flow CONF_CLOUD_SUBDEVICES = "cloud_subdevices" -# Keys -KEY_COORDINATOR = "coordinator" -KEY_DEVICE = "device" # Attributes ATTR_AVAILABLE = "available" diff --git a/homeassistant/components/xiaomi_miio/diagnostics.py b/homeassistant/components/xiaomi_miio/diagnostics.py index 749bea45f96..cc941b140be 100644 --- a/homeassistant/components/xiaomi_miio/diagnostics.py +++ b/homeassistant/components/xiaomi_miio/diagnostics.py @@ -5,11 +5,11 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_TOKEN, CONF_UNIQUE_ID +from homeassistant.const import CONF_DEVICE, CONF_MAC, CONF_TOKEN, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .const import CONF_CLOUD_PASSWORD, CONF_CLOUD_USERNAME, DOMAIN, KEY_COORDINATOR +from .const import CONF_CLOUD_PASSWORD, CONF_CLOUD_USERNAME, CONF_FLOW_TYPE +from .typing import XiaomiMiioConfigEntry TO_REDACT = { CONF_CLOUD_PASSWORD, @@ -21,18 +21,17 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: XiaomiMiioConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" diagnostics_data: dict[str, Any] = { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT) } - # not every device uses DataUpdateCoordinator - if coordinator := hass.data[DOMAIN][config_entry.entry_id].get(KEY_COORDINATOR): + if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + coordinator = config_entry.runtime_data.device_coordinator if isinstance(coordinator.data, dict): diagnostics_data["coordinator_data"] = coordinator.data else: diagnostics_data["coordinator_data"] = repr(coordinator.data) - return diagnostics_data diff --git a/homeassistant/components/xiaomi_miio/entity.py b/homeassistant/components/xiaomi_miio/entity.py index ba1148985ba..f5da22265c4 100644 --- a/homeassistant/components/xiaomi_miio/entity.py +++ b/homeassistant/components/xiaomi_miio/entity.py @@ -4,9 +4,10 @@ import datetime from enum import Enum from functools import partial import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from miio import DeviceException +from miio import Device as MiioDevice, DeviceException +from miio.gateway.devices import SubDevice from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC, CONF_MODEL from homeassistant.helpers import device_registry as dr @@ -18,6 +19,7 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ATTR_AVAILABLE, DOMAIN +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -25,34 +27,32 @@ _LOGGER = logging.getLogger(__name__) class XiaomiMiioEntity(Entity): """Representation of a base Xiaomi Miio Entity.""" - def __init__(self, name, device, entry, unique_id): + def __init__( + self, + name: str, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the Xiaomi Miio Device.""" self._device = device self._model = entry.data[CONF_MODEL] self._mac = entry.data[CONF_MAC] self._device_id = entry.unique_id - self._unique_id = unique_id - self._name = name - self._available = None - - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of this entity, if any.""" - return self._name + self._attr_unique_id = unique_id + self._attr_name = name + self._attr_available = False @property def device_info(self) -> DeviceInfo: """Return the device info.""" + if TYPE_CHECKING: + assert self._device_id is not None device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer="Xiaomi", model=self._model, - name=self._name, + name=self._attr_name, ) if self._mac is not None: @@ -68,7 +68,13 @@ class XiaomiCoordinatedMiioEntity[_T: DataUpdateCoordinator[Any]]( _attr_has_entity_name = True - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: _T, + ) -> None: """Initialize the coordinated Xiaomi Miio Device.""" super().__init__(coordinator) self._device = device @@ -76,16 +82,13 @@ class XiaomiCoordinatedMiioEntity[_T: DataUpdateCoordinator[Any]]( self._mac = entry.data[CONF_MAC] self._device_id = entry.unique_id self._device_name = entry.title - self._unique_id = unique_id - - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id + self._attr_unique_id = unique_id @property def device_info(self) -> DeviceInfo: """Return the device info.""" + if TYPE_CHECKING: + assert self._device_id is not None device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer="Xiaomi", @@ -150,30 +153,29 @@ class XiaomiCoordinatedMiioEntity[_T: DataUpdateCoordinator[Any]]( return time.isoformat() -class XiaomiGatewayDevice(CoordinatorEntity, Entity): +class XiaomiGatewayDevice( + CoordinatorEntity[DataUpdateCoordinator[dict[str, bool]]], Entity +): """Representation of a base Xiaomi Gateway Device.""" - def __init__(self, coordinator, sub_device, entry): + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, bool]], + sub_device: SubDevice, + entry: XiaomiMiioConfigEntry, + ) -> None: """Initialize the Xiaomi Gateway Device.""" super().__init__(coordinator) self._sub_device = sub_device self._entry = entry - self._unique_id = sub_device.sid - self._name = f"{sub_device.name} ({sub_device.sid})" - - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of this entity, if any.""" - return self._name + self._attr_unique_id = sub_device.sid + self._attr_name = f"{sub_device.name} ({sub_device.sid})" @property def device_info(self) -> DeviceInfo: """Return the device info of the gateway.""" + if TYPE_CHECKING: + assert self._entry.unique_id is not None return DeviceInfo( identifiers={(DOMAIN, self._sub_device.sid)}, via_device=(DOMAIN, self._entry.unique_id), diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 31d5dd9de2c..c69bd150226 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -8,6 +8,7 @@ import logging import math from typing import Any +from miio import Device as MiioDevice from miio.fan_common import ( MoveDirection as FanMoveDirection, OperationMode as FanOperationMode, @@ -30,11 +31,11 @@ from miio.integrations.fan.zhimi.zhimi_miot import ( import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -64,8 +65,6 @@ from .const import ( FEATURE_FLAGS_FAN_ZA5, FEATURE_RESET_FILTER, FEATURE_SET_EXTRA_FEATURES, - KEY_COORDINATOR, - KEY_DEVICE, MODEL_AIRFRESH_A1, MODEL_AIRFRESH_T2017, MODEL_AIRPURIFIER_2H, @@ -94,7 +93,7 @@ from .const import ( SERVICE_SET_EXTRA_FEATURES, ) from .entity import XiaomiCoordinatedMiioEntity -from .typing import ServiceMethodDetails +from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -204,7 +203,7 @@ FAN_DIRECTIONS_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fan from a config entry.""" @@ -218,8 +217,8 @@ async def async_setup_entry( model = config_entry.data[CONF_MODEL] unique_id = config_entry.unique_id - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + device = config_entry.runtime_data.device + coordinator = config_entry.runtime_data.device_coordinator if model in (MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_3C_REV_A): entity = XiaomiAirPurifierMB4( @@ -296,48 +295,41 @@ async def async_setup_entry( async_add_entities(entities) -class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): +class XiaomiGenericDevice( + XiaomiCoordinatedMiioEntity[DataUpdateCoordinator[Any]], FanEntity +): """Representation of a generic Xiaomi device.""" _attr_name = None + _attr_preset_modes: list[str] - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the generic Xiaomi device.""" super().__init__(device, entry, unique_id, coordinator) - self._available_attributes = {} - self._state = None - self._mode = None - self._fan_level = None - self._state_attrs = {} + self._available_attributes: dict[str, Any] = {} + self._mode: str | None = None + self._fan_level: int | None = None + self._attr_extra_state_attributes = {} self._device_features = 0 - self._preset_modes = [] + self._attr_preset_modes = [] @property @abstractmethod def operation_mode_class(self): """Hold operation mode class.""" - @property - def preset_modes(self) -> list[str]: - """Get the list of available preset modes.""" - return self._preset_modes - @property def percentage(self) -> int | None: """Return the percentage based speed of the fan.""" return None - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the device.""" - return self._state_attrs - - @property - def is_on(self) -> bool | None: - """Return true if device is on.""" - return self._state - async def async_turn_on( self, percentage: int | None = None, @@ -346,7 +338,8 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): ) -> None: """Turn the device on.""" result = await self._try_command( - "Turning the miio device on failed.", self._device.on + "Turning the miio device on failed.", + self._device.on, # type: ignore[attr-defined] ) # If operation mode was set the device must not be turned on. @@ -356,48 +349,38 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): await self.async_set_preset_mode(preset_mode) if result: - self._state = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" result = await self._try_command( - "Turning the miio device off failed.", self._device.off + "Turning the miio device off failed.", + self._device.off, # type: ignore[attr-defined] ) if result: - self._state = False + self._attr_is_on = False self.async_write_ha_state() class XiaomiGenericAirPurifier(XiaomiGenericDevice): """Representation of a generic AirPurifier device.""" - def __init__(self, device, entry, unique_id, coordinator): - """Initialize the generic AirPurifier device.""" - super().__init__(device, entry, unique_id, coordinator) - - self._speed_count = 100 - - @property - def speed_count(self) -> int: - """Return the number of speeds of the fan supported.""" - return self._speed_count - @property def preset_mode(self) -> str | None: """Get the active preset mode.""" - if self._state: + if self._attr_is_on: preset_mode = self.operation_mode_class(self._mode).name - return preset_mode if preset_mode in self._preset_modes else None + return preset_mode if preset_mode in self._attr_preset_modes else None return None @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._state = self.coordinator.data.is_on - self._state_attrs.update( + self._attr_is_on = self.coordinator.data.is_on + self._attr_extra_state_attributes.update( { key: self._extract_value_from_attribute(self.coordinator.data, value) for key, value in self._available_attributes.items() @@ -420,77 +403,83 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): REVERSE_SPEED_MODE_MAPPING = {v: k for k, v in SPEED_MODE_MAPPING.items()} - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the plug switch.""" super().__init__(device, entry, unique_id, coordinator) if self._model == MODEL_AIRPURIFIER_PRO: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO - self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER_PRO self._attr_supported_features = FanEntityFeature.PRESET_MODE - self._speed_count = 1 + self._attr_speed_count = 1 elif self._model in [MODEL_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_PRO]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_4 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT - self._preset_modes = PRESET_MODES_AIRPURIFIER_MIOT + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER_MIOT self._attr_supported_features = ( FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE ) - self._speed_count = 3 + self._attr_speed_count = 3 elif self._model in [ MODEL_AIRPURIFIER_4_LITE_RMA1, MODEL_AIRPURIFIER_4_LITE_RMB1, ]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_4_LITE self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT - self._preset_modes = PRESET_MODES_AIRPURIFIER_4_LITE + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER_4_LITE self._attr_supported_features = FanEntityFeature.PRESET_MODE - self._speed_count = 1 + self._attr_speed_count = 1 elif self._model == MODEL_AIRPURIFIER_PRO_V7: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 - self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO_V7 + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER_PRO_V7 self._attr_supported_features = FanEntityFeature.PRESET_MODE - self._speed_count = 1 + self._attr_speed_count = 1 elif self._model in [MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON - self._preset_modes = PRESET_MODES_AIRPURIFIER_2S + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER_2S self._attr_supported_features = FanEntityFeature.PRESET_MODE - self._speed_count = 1 + self._attr_speed_count = 1 elif self._model == MODEL_AIRPURIFIER_ZA1: self._device_features = FEATURE_FLAGS_AIRPURIFIER_ZA1 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT - self._preset_modes = PRESET_MODES_AIRPURIFIER_ZA1 + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER_ZA1 self._attr_supported_features = FanEntityFeature.PRESET_MODE - self._speed_count = 1 + self._attr_speed_count = 1 elif self._model in MODELS_PURIFIER_MIOT: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIOT self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT - self._preset_modes = PRESET_MODES_AIRPURIFIER_MIOT + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER_MIOT self._attr_supported_features = ( FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE ) - self._speed_count = 3 + self._attr_speed_count = 3 elif self._model == MODEL_AIRPURIFIER_V3: self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 - self._preset_modes = PRESET_MODES_AIRPURIFIER_V3 + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER_V3 self._attr_supported_features = FanEntityFeature.PRESET_MODE - self._speed_count = 1 + self._attr_speed_count = 1 else: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIIO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER - self._preset_modes = PRESET_MODES_AIRPURIFIER + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER self._attr_supported_features = FanEntityFeature.PRESET_MODE - self._speed_count = 1 + self._attr_speed_count = 1 self._attr_supported_features |= ( FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - self._state = self.coordinator.data.is_on - self._state_attrs.update( + self._attr_is_on = self.coordinator.data.is_on + self._attr_extra_state_attributes.update( { key: self._extract_value_from_attribute(self.coordinator.data, value) for key, value in self._available_attributes.items() @@ -507,11 +496,11 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): @property def percentage(self) -> int | None: """Return the current percentage based speed.""" - if self._state: + if self._attr_is_on: mode = self.operation_mode_class(self._mode) if mode in self.REVERSE_SPEED_MODE_MAPPING: return ranged_value_to_percentage( - (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] + (1, self.speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] ) return None @@ -526,12 +515,12 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): return speed_mode = math.ceil( - percentage_to_ranged_value((1, self._speed_count), percentage) + percentage_to_ranged_value((1, self.speed_count), percentage) ) if speed_mode: await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] self.operation_mode_class(self.SPEED_MODE_MAPPING[speed_mode]), ) @@ -542,7 +531,7 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): """ if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] self.operation_mode_class[preset_mode], ): self._mode = self.operation_mode_class[preset_mode].value @@ -555,7 +544,7 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): await self._try_command( "Setting the extra features of the miio device failed.", - self._device.set_extra_features, + self._device.set_extra_features, # type: ignore[attr-defined] features, ) @@ -583,7 +572,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): """Return the current percentage based speed.""" if self._fan_level is None: return None - if self._state: + if self._attr_is_on: return ranged_value_to_percentage((1, 3), self._fan_level) return None @@ -602,7 +591,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): return if await self._try_command( "Setting fan level of the miio device failed.", - self._device.set_fan_level, + self._device.set_fan_level, # type: ignore[attr-defined] fan_level, ): self._fan_level = fan_level @@ -612,12 +601,18 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): """Representation of a Xiaomi Air Purifier MB4.""" - def __init__(self, device, entry, unique_id, coordinator) -> None: + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize Air Purifier MB4.""" super().__init__(device, entry, unique_id, coordinator) self._device_features = FEATURE_FLAGS_AIRPURIFIER_3C - self._preset_modes = PRESET_MODES_AIRPURIFIER_3C + self._attr_preset_modes = PRESET_MODES_AIRPURIFIER_3C self._attr_supported_features = ( FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE @@ -625,7 +620,7 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): | FanEntityFeature.TURN_ON ) - self._state = self.coordinator.data.is_on + self._attr_is_on = self.coordinator.data.is_on self._mode = self.coordinator.data.mode.value self._favorite_rpm: int | None = None self._speed_range = (300, 2200) @@ -644,7 +639,7 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): return ranged_value_to_percentage(self._speed_range, self._motor_speed) if self._favorite_rpm is None: return None - if self._state: + if self._attr_is_on: return ranged_value_to_percentage(self._speed_range, self._favorite_rpm) return None @@ -662,7 +657,7 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): return if await self._try_command( "Setting fan level of the miio device failed.", - self._device.set_favorite_rpm, + self._device.set_favorite_rpm, # type: ignore[attr-defined] favorite_rpm, ): self._favorite_rpm = favorite_rpm @@ -671,12 +666,12 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if not self._state: + if not self._attr_is_on: await self.async_turn_on() if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] self.operation_mode_class[preset_mode], ): self._mode = self.operation_mode_class[preset_mode].value @@ -685,7 +680,7 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._state = self.coordinator.data.is_on + self._attr_is_on = self.coordinator.data.is_on self._mode = self.coordinator.data.mode.value self._favorite_rpm = getattr(self.coordinator.data, ATTR_FAVORITE_RPM, None) self._motor_speed = min( @@ -715,14 +710,20 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): "Interval": AirfreshOperationMode.Interval, } - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the miio device.""" super().__init__(device, entry, unique_id, coordinator) self._device_features = FEATURE_FLAGS_AIRFRESH self._available_attributes = AVAILABLE_ATTRIBUTES_AIRFRESH - self._speed_count = 4 - self._preset_modes = PRESET_MODES_AIRFRESH + self._attr_speed_count = 4 + self._attr_preset_modes = PRESET_MODES_AIRFRESH self._attr_supported_features = ( FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE @@ -730,8 +731,8 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): | FanEntityFeature.TURN_ON ) - self._state = self.coordinator.data.is_on - self._state_attrs.update( + self._attr_is_on = self.coordinator.data.is_on + self._attr_extra_state_attributes.update( { key: getattr(self.coordinator.data, value) for key, value in self._available_attributes.items() @@ -747,11 +748,11 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): @property def percentage(self) -> int | None: """Return the current percentage based speed.""" - if self._state: + if self._attr_is_on: mode = AirfreshOperationMode(self._mode) if mode in self.REVERSE_SPEED_MODE_MAPPING: return ranged_value_to_percentage( - (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] + (1, self.speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] ) return None @@ -762,12 +763,12 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): This method is a coroutine. """ speed_mode = math.ceil( - percentage_to_ranged_value((1, self._speed_count), percentage) + percentage_to_ranged_value((1, self.speed_count), percentage) ) if speed_mode: if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] AirfreshOperationMode(self.SPEED_MODE_MAPPING[speed_mode]), ): self._mode = AirfreshOperationMode( @@ -782,7 +783,7 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): """ if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] self.operation_mode_class[preset_mode], ): self._mode = self.operation_mode_class[preset_mode].value @@ -795,7 +796,7 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): await self._try_command( "Setting the extra features of the miio device failed.", - self._device.set_extra_features, + self._device.set_extra_features, # type: ignore[attr-defined] features, ) @@ -813,12 +814,18 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): class XiaomiAirFreshA1(XiaomiGenericAirPurifier): """Representation of a Xiaomi Air Fresh A1.""" - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the miio device.""" super().__init__(device, entry, unique_id, coordinator) - self._favorite_speed = None + self._favorite_speed: int | None = None self._device_features = FEATURE_FLAGS_AIRFRESH_A1 - self._preset_modes = PRESET_MODES_AIRFRESH_A1 + self._attr_preset_modes = PRESET_MODES_AIRFRESH_A1 self._attr_supported_features = ( FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE @@ -826,7 +833,7 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): | FanEntityFeature.TURN_ON ) - self._state = self.coordinator.data.is_on + self._attr_is_on = self.coordinator.data.is_on self._mode = self.coordinator.data.mode.value self._speed_range = (60, 150) @@ -840,7 +847,7 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): """Return the current percentage based speed.""" if self._favorite_speed is None: return None - if self._state: + if self._attr_is_on: return ranged_value_to_percentage(self._speed_range, self._favorite_speed) return None @@ -860,7 +867,7 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): return if await self._try_command( "Setting fan level of the miio device failed.", - self._device.set_favorite_speed, + self._device.set_favorite_speed, # type: ignore[attr-defined] favorite_speed, ): self._favorite_speed = favorite_speed @@ -870,7 +877,7 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): """Set the preset mode of the fan. This method is a coroutine.""" if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] self.operation_mode_class[preset_mode], ): self._mode = self.operation_mode_class[preset_mode].value @@ -879,7 +886,7 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._state = self.coordinator.data.is_on + self._attr_is_on = self.coordinator.data.is_on self._mode = self.coordinator.data.mode.value self._favorite_speed = getattr(self.coordinator.data, ATTR_FAVORITE_SPEED, None) self.async_write_ha_state() @@ -888,7 +895,13 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): class XiaomiAirFreshT2017(XiaomiAirFreshA1): """Representation of a Xiaomi Air Fresh T2017.""" - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the miio device.""" super().__init__(device, entry, unique_id, coordinator) self._device_features = FEATURE_FLAGS_AIRFRESH_T2017 @@ -900,7 +913,13 @@ class XiaomiGenericFan(XiaomiGenericDevice): _attr_translation_key = "generic_fan" - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the fan.""" super().__init__(device, entry, unique_id, coordinator) @@ -925,14 +944,7 @@ class XiaomiGenericFan(XiaomiGenericDevice): ) if self._model != MODEL_FAN_1C: self._attr_supported_features |= FanEntityFeature.DIRECTION - self._preset_mode = None - self._oscillating = None - self._percentage = None - - @property - def preset_mode(self) -> str | None: - """Get the active preset mode.""" - return self._preset_mode + self._percentage: int | None = None @property def preset_modes(self) -> list[str]: @@ -942,34 +954,29 @@ class XiaomiGenericFan(XiaomiGenericDevice): @property def percentage(self) -> int | None: """Return the current speed as a percentage.""" - if self._state: + if self._attr_is_on: return self._percentage return None - @property - def oscillating(self) -> bool | None: - """Return whether or not the fan is currently oscillating.""" - return self._oscillating - async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation.""" await self._try_command( "Setting oscillate on/off of the miio device failed.", - self._device.set_oscillate, + self._device.set_oscillate, # type: ignore[attr-defined] oscillating, ) - self._oscillating = oscillating + self._attr_oscillating = oscillating self.async_write_ha_state() async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - if self._oscillating: + if self._attr_oscillating: await self.async_oscillate(oscillating=False) await self._try_command( "Setting move direction of the miio device failed.", - self._device.set_rotate, + self._device.set_rotate, # type: ignore[attr-defined] FanMoveDirection(FAN_DIRECTIONS_MAP[direction]), ) @@ -977,12 +984,18 @@ class XiaomiGenericFan(XiaomiGenericDevice): class XiaomiFan(XiaomiGenericFan): """Representation of a Xiaomi Fan.""" - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the fan.""" super().__init__(device, entry, unique_id, coordinator) - self._state = self.coordinator.data.is_on - self._oscillating = self.coordinator.data.oscillate + self._attr_is_on = self.coordinator.data.is_on + self._attr_oscillating = self.coordinator.data.oscillate self._nature_mode = self.coordinator.data.natural_speed != 0 if self._nature_mode: self._percentage = self.coordinator.data.natural_speed @@ -1006,8 +1019,8 @@ class XiaomiFan(XiaomiGenericFan): @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._state = self.coordinator.data.is_on - self._oscillating = self.coordinator.data.oscillate + self._attr_is_on = self.coordinator.data.is_on + self._attr_oscillating = self.coordinator.data.oscillate self._nature_mode = self.coordinator.data.natural_speed != 0 if self._nature_mode: self._percentage = self.coordinator.data.natural_speed @@ -1021,17 +1034,17 @@ class XiaomiFan(XiaomiGenericFan): if preset_mode == ATTR_MODE_NATURE: await self._try_command( "Setting natural fan speed percentage of the miio device failed.", - self._device.set_natural_speed, + self._device.set_natural_speed, # type: ignore[attr-defined] self._percentage, ) else: await self._try_command( "Setting direct fan speed percentage of the miio device failed.", - self._device.set_direct_speed, + self._device.set_direct_speed, # type: ignore[attr-defined] self._percentage, ) - self._preset_mode = preset_mode + self._attr_preset_mode = preset_mode self.async_write_ha_state() async def async_set_percentage(self, percentage: int) -> None: @@ -1044,13 +1057,13 @@ class XiaomiFan(XiaomiGenericFan): if self._nature_mode: await self._try_command( "Setting fan speed percentage of the miio device failed.", - self._device.set_natural_speed, + self._device.set_natural_speed, # type: ignore[attr-defined] percentage, ) else: await self._try_command( "Setting fan speed percentage of the miio device failed.", - self._device.set_direct_speed, + self._device.set_direct_speed, # type: ignore[attr-defined] percentage, ) self._percentage = percentage @@ -1064,13 +1077,19 @@ class XiaomiFan(XiaomiGenericFan): class XiaomiFanP5(XiaomiGenericFan): """Representation of a Xiaomi Fan P5.""" - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the fan.""" super().__init__(device, entry, unique_id, coordinator) - self._state = self.coordinator.data.is_on - self._preset_mode = self.coordinator.data.mode.name - self._oscillating = self.coordinator.data.oscillate + self._attr_is_on = self.coordinator.data.is_on + self._attr_preset_mode = self.coordinator.data.mode.name + self._attr_oscillating = self.coordinator.data.oscillate self._percentage = self.coordinator.data.speed @property @@ -1081,9 +1100,9 @@ class XiaomiFanP5(XiaomiGenericFan): @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._state = self.coordinator.data.is_on - self._preset_mode = self.coordinator.data.mode.name - self._oscillating = self.coordinator.data.oscillate + self._attr_is_on = self.coordinator.data.is_on + self._attr_preset_mode = self.coordinator.data.mode.name + self._attr_oscillating = self.coordinator.data.oscillate self._percentage = self.coordinator.data.speed self.async_write_ha_state() @@ -1092,10 +1111,10 @@ class XiaomiFanP5(XiaomiGenericFan): """Set the preset mode of the fan.""" await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] self.operation_mode_class[preset_mode], ) - self._preset_mode = preset_mode + self._attr_preset_mode = preset_mode self.async_write_ha_state() async def async_set_percentage(self, percentage: int) -> None: @@ -1107,7 +1126,7 @@ class XiaomiFanP5(XiaomiGenericFan): await self._try_command( "Setting fan speed percentage of the miio device failed.", - self._device.set_speed, + self._device.set_speed, # type: ignore[attr-defined] percentage, ) self._percentage = percentage @@ -1126,17 +1145,12 @@ class XiaomiFanMiot(XiaomiGenericFan): """Hold operation mode class.""" return FanOperationMode - @property - def preset_mode(self) -> str | None: - """Get the active preset mode.""" - return self._preset_mode - @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._state = self.coordinator.data.is_on - self._preset_mode = self.coordinator.data.mode.name - self._oscillating = self.coordinator.data.oscillate + self._attr_is_on = self.coordinator.data.is_on + self._attr_preset_mode = self.coordinator.data.mode.name + self._attr_oscillating = self.coordinator.data.oscillate if self.coordinator.data.is_on: self._percentage = self.coordinator.data.speed else: @@ -1148,10 +1162,10 @@ class XiaomiFanMiot(XiaomiGenericFan): """Set the preset mode of the fan.""" await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] self.operation_mode_class[preset_mode], ) - self._preset_mode = preset_mode + self._attr_preset_mode = preset_mode self.async_write_ha_state() async def async_set_percentage(self, percentage: int) -> None: @@ -1163,7 +1177,7 @@ class XiaomiFanMiot(XiaomiGenericFan): result = await self._try_command( "Setting fan speed percentage of the miio device failed.", - self._device.set_speed, + self._device.set_speed, # type: ignore[attr-defined] percentage, ) if result: @@ -1187,20 +1201,26 @@ class XiaomiFanZA5(XiaomiFanMiot): class XiaomiFan1C(XiaomiFanMiot): """Representation of a Xiaomi Fan 1C (Standing Fan 2 Lite).""" - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize MIOT fan with speed count.""" super().__init__(device, entry, unique_id, coordinator) - self._speed_count = 3 + self._attr_speed_count = 3 @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._state = self.coordinator.data.is_on - self._preset_mode = self.coordinator.data.mode.name - self._oscillating = self.coordinator.data.oscillate + self._attr_is_on = self.coordinator.data.is_on + self._attr_preset_mode = self.coordinator.data.mode.name + self._attr_oscillating = self.coordinator.data.oscillate if self.coordinator.data.is_on: self._percentage = ranged_value_to_percentage( - (1, self._speed_count), self.coordinator.data.speed + (1, self.speed_count), self.coordinator.data.speed ) else: self._percentage = 0 @@ -1214,9 +1234,7 @@ class XiaomiFan1C(XiaomiFanMiot): await self.async_turn_off() return - speed = math.ceil( - percentage_to_ranged_value((1, self._speed_count), percentage) - ) + speed = math.ceil(percentage_to_ranged_value((1, self.speed_count), percentage)) # if the fan is not on, we have to turn it on first if not self.is_on: @@ -1224,10 +1242,10 @@ class XiaomiFan1C(XiaomiFanMiot): result = await self._try_command( "Setting fan speed percentage of the miio device failed.", - self._device.set_speed, + self._device.set_speed, # type: ignore[attr-defined] speed, ) if result: - self._percentage = ranged_value_to_percentage((1, self._speed_count), speed) + self._percentage = ranged_value_to_percentage((1, self.speed_count), speed) self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index f19fbec5e78..49ae58ed2ef 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -4,6 +4,7 @@ import logging import math from typing import Any +from miio import Device as MiioDevice from miio.integrations.humidifier.deerma.airhumidifier_mjjsq import ( OperationMode as AirhumidifierMjjsqOperationMode, ) @@ -20,17 +21,14 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.percentage import percentage_to_ranged_value from .const import ( CONF_FLOW_TYPE, - DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, @@ -38,6 +36,7 @@ from .const import ( MODELS_HUMIDIFIER_MJJSQ, ) from .entity import XiaomiCoordinatedMiioEntity +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -70,7 +69,7 @@ AVAILABLE_MODES_OTHER = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Humidifier from a config entry.""" @@ -81,28 +80,26 @@ async def async_setup_entry( entity: HumidifierEntity model = config_entry.data[CONF_MODEL] unique_id = config_entry.unique_id - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + device = config_entry.runtime_data.device + coordinator = config_entry.runtime_data.device_coordinator if model in MODELS_HUMIDIFIER_MIOT: - air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] entity = XiaomiAirHumidifierMiot( - air_humidifier, + device, config_entry, unique_id, coordinator, ) elif model in MODELS_HUMIDIFIER_MJJSQ: - air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] entity = XiaomiAirHumidifierMjjsq( - air_humidifier, + device, config_entry, unique_id, coordinator, ) else: - air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] entity = XiaomiAirHumidifier( - air_humidifier, + device, config_entry, unique_id, coordinator, @@ -113,50 +110,49 @@ async def async_setup_entry( async_add_entities(entities) -class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): +class XiaomiGenericHumidifier( + XiaomiCoordinatedMiioEntity[DataUpdateCoordinator[Any]], HumidifierEntity +): """Representation of a generic Xiaomi humidifier device.""" _attr_device_class = HumidifierDeviceClass.HUMIDIFIER _attr_supported_features = HumidifierEntityFeature.MODES _attr_name = None - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the generic Xiaomi device.""" super().__init__(device, entry, unique_id, coordinator=coordinator) - self._state = None - self._attributes = {} - self._mode = None + self._attributes: dict[str, Any] = {} + self._mode: str | int | None = None self._humidity_steps = 100 - self._target_humidity = None - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - @property - def mode(self): - """Get the current mode.""" - return self._mode + self._target_humidity: float | None = None async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" result = await self._try_command( - "Turning the miio device on failed.", self._device.on + "Turning the miio device on failed.", + self._device.on, # type: ignore[attr-defined] ) if result: - self._state = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" result = await self._try_command( - "Turning the miio device off failed.", self._device.off + "Turning the miio device off failed.", + self._device.off, # type: ignore[attr-defined] ) if result: - self._state = False + self._attr_is_on = False self.async_write_ha_state() def translate_humidity(self, humidity: float) -> float | None: @@ -175,7 +171,13 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): available_modes: list[str] - def __init__(self, device, entry, unique_id, coordinator): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + coordinator: DataUpdateCoordinator[Any], + ) -> None: """Initialize the plug switch.""" super().__init__(device, entry, unique_id, coordinator) @@ -194,7 +196,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): self._attr_available_modes = AVAILABLE_MODES_OTHER self._humidity_steps = 10 - self._state = self.coordinator.data.is_on + self._attr_is_on = self.coordinator.data.is_on self._attributes.update( { key: self._extract_value_from_attribute(self.coordinator.data, value) @@ -205,15 +207,10 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): self._attr_current_humidity = self._attributes[ATTR_HUMIDITY] self._mode = self._attributes[ATTR_MODE] - @property - def is_on(self): - """Return true if device is on.""" - return self._state - @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._state = self.coordinator.data.is_on + self._attr_is_on = self.coordinator.data.is_on self._attributes.update( { key: self._extract_value_from_attribute(self.coordinator.data, value) @@ -222,16 +219,16 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): ) self._target_humidity = self._attributes[ATTR_TARGET_HUMIDITY] self._attr_current_humidity = self._attributes[ATTR_HUMIDITY] - self._mode = self._attributes[ATTR_MODE] + self._attr_mode = self._attributes[ATTR_MODE] self.async_write_ha_state() @property - def mode(self): + def mode(self) -> str: """Return the current mode.""" return AirhumidifierOperationMode(self._mode).name @property - def target_humidity(self): + def target_humidity(self) -> float | None: """Return the target humidity.""" return ( self._target_humidity @@ -249,7 +246,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): _LOGGER.debug("Setting the target humidity to: %s", target_humidity) if await self._try_command( "Setting target humidity of the miio device failed.", - self._device.set_target_humidity, + self._device.set_target_humidity, # type: ignore[attr-defined] target_humidity, ): self._target_humidity = target_humidity @@ -264,7 +261,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): _LOGGER.debug("Setting the operation mode to: Auto") if await self._try_command( "Setting operation mode of the miio device to MODE_AUTO failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] AirhumidifierOperationMode.Auto, ): self._mode = AirhumidifierOperationMode.Auto.value @@ -282,7 +279,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): _LOGGER.debug("Setting the operation mode to: %s", mode) if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] AirhumidifierOperationMode[mode], ): self._mode = mode.lower() @@ -302,14 +299,14 @@ class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): REVERSE_MODE_MAPPING = {v: k for k, v in MODE_MAPPING.items()} @property - def mode(self): + def mode(self) -> str: """Return the current mode.""" return AirhumidifierMiotOperationMode(self._mode).name @property - def target_humidity(self): + def target_humidity(self) -> float | None: """Return the target humidity.""" - if self._state: + if self.is_on: return ( self._target_humidity if AirhumidifierMiotOperationMode(self._mode) @@ -327,7 +324,7 @@ class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): _LOGGER.debug("Setting the humidity to: %s", target_humidity) if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_target_humidity, + self._device.set_target_humidity, # type: ignore[attr-defined] target_humidity, ): self._target_humidity = target_humidity @@ -341,7 +338,7 @@ class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): _LOGGER.debug("Setting the operation mode to: Auto") if await self._try_command( "Setting operation mode of the miio device to MODE_AUTO failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] AirhumidifierMiotOperationMode.Auto, ): self._mode = 0 @@ -357,10 +354,10 @@ class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): return _LOGGER.debug("Setting the operation mode to: %s", mode) - if self._state: + if self.is_on: if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] self.REVERSE_MODE_MAPPING[mode], ): self._mode = self.REVERSE_MODE_MAPPING[mode].value @@ -378,14 +375,14 @@ class XiaomiAirHumidifierMjjsq(XiaomiAirHumidifier): } @property - def mode(self): + def mode(self) -> str: """Return the current mode.""" return AirhumidifierMjjsqOperationMode(self._mode).name @property - def target_humidity(self): + def target_humidity(self) -> float | None: """Return the target humidity.""" - if self._state: + if self.is_on: if ( AirhumidifierMjjsqOperationMode(self._mode) == AirhumidifierMjjsqOperationMode.Humidity @@ -402,7 +399,7 @@ class XiaomiAirHumidifierMjjsq(XiaomiAirHumidifier): _LOGGER.debug("Setting the humidity to: %s", target_humidity) if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_target_humidity, + self._device.set_target_humidity, # type: ignore[attr-defined] target_humidity, ): self._target_humidity = target_humidity @@ -416,7 +413,7 @@ class XiaomiAirHumidifierMjjsq(XiaomiAirHumidifier): _LOGGER.debug("Setting the operation mode to: Humidity") if await self._try_command( "Setting operation mode of the miio device to MODE_HUMIDITY failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] AirhumidifierMjjsqOperationMode.Humidity, ): self._mode = 3 @@ -429,10 +426,10 @@ class XiaomiAirHumidifierMjjsq(XiaomiAirHumidifier): return _LOGGER.debug("Setting the operation mode to: %s", mode) - if self._state: + if self.is_on: if await self._try_command( "Setting operation mode of the miio device failed.", - self._device.set_mode, + self._device.set_mode, # type: ignore[attr-defined] self.MODE_MAPPING[mode], ): self._mode = self.MODE_MAPPING[mode].value diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 81f68306cbc..0ff6df93d3e 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -18,6 +18,7 @@ from miio import ( PhilipsEyecare, PhilipsMoonlight, ) +from miio.gateway.devices.light import LightBulb from miio.gateway.gateway import ( GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, @@ -33,7 +34,6 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE, @@ -51,7 +51,6 @@ from .const import ( CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, - KEY_COORDINATOR, MODELS_LIGHT_BULB, MODELS_LIGHT_CEILING, MODELS_LIGHT_EYECARE, @@ -67,7 +66,7 @@ from .const import ( SERVICE_SET_SCENE, ) from .entity import XiaomiGatewayDevice, XiaomiMiioEntity -from .typing import ServiceMethodDetails +from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -131,7 +130,7 @@ SERVICE_TO_METHOD = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi light from a config entry.""" @@ -140,7 +139,7 @@ async def async_setup_entry( light: MiioDevice if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: - gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] + gateway = config_entry.runtime_data.gateway # Gateway light if gateway.model not in [ GATEWAY_MODEL_AC_V1, @@ -154,7 +153,7 @@ async def async_setup_entry( sub_devices = gateway.devices for sub_device in sub_devices.values(): if sub_device.device_type == "LightBulb": - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR][ + coordinator = config_entry.runtime_data.gateway_coordinators[ sub_device.sid ] entities.append( @@ -260,35 +259,19 @@ class XiaomiPhilipsAbstractLight(XiaomiMiioEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _device: Ceil | PhilipsBulb | PhilipsEyecare | PhilipsMoonlight - def __init__(self, name, device, entry, unique_id): + def __init__( + self, + name: str, + device: Ceil | PhilipsBulb | PhilipsEyecare | PhilipsMoonlight, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the light device.""" super().__init__(name, device, entry, unique_id) - self._brightness = None - self._available = False - self._state = None - self._state_attrs = {} - - @property - def available(self): - """Return true when state is known.""" - return self._available - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - return self._state_attrs - - @property - def is_on(self): - """Return true if light is on.""" - return self._state - - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._brightness + self._attr_extra_state_attributes = {} async def _try_command(self, mask_error, func, *args, **kwargs): """Call a light command handling error messages.""" @@ -297,9 +280,9 @@ class XiaomiPhilipsAbstractLight(XiaomiMiioEntity, LightEntity): partial(func, *args, **kwargs) ) except DeviceException as exc: - if self._available: + if self._attr_available: _LOGGER.error(mask_error, exc) - self._available = False + self._attr_available = False return False @@ -321,7 +304,7 @@ class XiaomiPhilipsAbstractLight(XiaomiMiioEntity, LightEntity): ) if result: - self._brightness = brightness + self._attr_brightness = brightness else: await self._try_command("Turning the light on failed.", self._device.on) @@ -334,50 +317,60 @@ class XiaomiPhilipsAbstractLight(XiaomiMiioEntity, LightEntity): try: state = await self.hass.async_add_executor_job(self._device.status) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) return _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) + self._attr_available = True + self._attr_is_on = state.is_on + self._attr_brightness = ceil((255 / 100.0) * state.brightness) class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): """Representation of a Generic Xiaomi Philips Light.""" - def __init__(self, name, device, entry, unique_id): + _device: Ceil | PhilipsBulb | PhilipsEyecare | PhilipsMoonlight + + def __init__( + self, + name: str, + device: Ceil | PhilipsBulb | PhilipsEyecare | PhilipsMoonlight, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the light device.""" super().__init__(name, device, entry, unique_id) - self._state_attrs.update({ATTR_SCENE: None, ATTR_DELAYED_TURN_OFF: None}) + self._attr_extra_state_attributes.update( + {ATTR_SCENE: None, ATTR_DELAYED_TURN_OFF: None} + ) async def async_update(self) -> None: """Fetch state from the device.""" try: state = await self.hass.async_add_executor_job(self._device.status) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) return _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) + self._attr_available = True + self._attr_is_on = state.is_on + self._attr_brightness = ceil((255 / 100.0) * state.brightness) delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, dt_util.utcnow(), - self._state_attrs[ATTR_DELAYED_TURN_OFF], + self._attr_extra_state_attributes[ATTR_DELAYED_TURN_OFF], ) - self._state_attrs.update( + self._attr_extra_state_attributes.update( {ATTR_SCENE: state.scene, ATTR_DELAYED_TURN_OFF: delayed_turn_off} ) @@ -391,7 +384,7 @@ class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): """Set delayed turn off.""" await self._try_command( "Setting the turn off delay failed.", - self._device.delay_off, + self._device.delay_off, # type: ignore[union-attr] time_period.total_seconds(), ) @@ -422,12 +415,19 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): _attr_color_mode = ColorMode.COLOR_TEMP _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + _device: Ceil | PhilipsBulb | PhilipsMoonlight - def __init__(self, name, device, entry, unique_id): + def __init__( + self, + name: str, + device: Ceil | PhilipsBulb | PhilipsMoonlight, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the light device.""" super().__init__(name, device, entry, unique_id) - self._color_temp = None + self._color_temp: int | None = None @property def _current_mireds(self): @@ -495,7 +495,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): if result: self._color_temp = color_temp - self._brightness = brightness + self._attr_brightness = brightness elif ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( @@ -526,7 +526,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): ) if result: - self._brightness = brightness + self._attr_brightness = brightness else: await self._try_command("Turning the light on failed.", self._device.on) @@ -536,16 +536,16 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): try: state = await self.hass.async_add_executor_job(self._device.status) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) return _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) + self._attr_available = True + self._attr_is_on = state.is_on + self._attr_brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( state.color_temperature, CCT_MIN, @@ -557,10 +557,10 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, dt_util.utcnow(), - self._state_attrs[ATTR_DELAYED_TURN_OFF], + self._attr_extra_state_attributes[ATTR_DELAYED_TURN_OFF], ) - self._state_attrs.update( + self._attr_extra_state_attributes.update( {ATTR_SCENE: state.scene, ATTR_DELAYED_TURN_OFF: delayed_turn_off} ) @@ -576,11 +576,19 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): """Representation of a Xiaomi Philips Ceiling Lamp.""" - def __init__(self, name, device, entry, unique_id): + _device: Ceil + + def __init__( + self, + name: str, + device: Ceil, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the light device.""" super().__init__(name, device, entry, unique_id) - self._state_attrs.update( + self._attr_extra_state_attributes.update( {ATTR_NIGHT_LIGHT_MODE: None, ATTR_AUTOMATIC_COLOR_TEMPERATURE: None} ) @@ -599,16 +607,16 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): try: state = await self.hass.async_add_executor_job(self._device.status) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) return _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) + self._attr_available = True + self._attr_is_on = state.is_on + self._attr_brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( state.color_temperature, CCT_MIN, @@ -620,10 +628,10 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, dt_util.utcnow(), - self._state_attrs[ATTR_DELAYED_TURN_OFF], + self._attr_extra_state_attributes[ATTR_DELAYED_TURN_OFF], ) - self._state_attrs.update( + self._attr_extra_state_attributes.update( { ATTR_SCENE: state.scene, ATTR_DELAYED_TURN_OFF: delayed_turn_off, @@ -636,11 +644,19 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight): """Representation of a Xiaomi Philips Eyecare Lamp 2.""" - def __init__(self, name, device, entry, unique_id): + _device: PhilipsEyecare + + def __init__( + self, + name: str, + device: PhilipsEyecare, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the light device.""" super().__init__(name, device, entry, unique_id) - self._state_attrs.update( + self._attr_extra_state_attributes.update( {ATTR_REMINDER: None, ATTR_NIGHT_LIGHT_MODE: None, ATTR_EYECARE_MODE: None} ) @@ -649,24 +665,24 @@ class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight): try: state = await self.hass.async_add_executor_job(self._device.status) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) return _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) + self._attr_available = True + self._attr_is_on = state.is_on + self._attr_brightness = ceil((255 / 100.0) * state.brightness) delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, dt_util.utcnow(), - self._state_attrs[ATTR_DELAYED_TURN_OFF], + self._attr_extra_state_attributes[ATTR_DELAYED_TURN_OFF], ) - self._state_attrs.update( + self._attr_extra_state_attributes.update( { ATTR_SCENE: state.scene, ATTR_DELAYED_TURN_OFF: delayed_turn_off, @@ -749,7 +765,15 @@ class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight): class XiaomiPhilipsEyecareLampAmbientLight(XiaomiPhilipsAbstractLight): """Representation of a Xiaomi Philips Eyecare Lamp Ambient Light.""" - def __init__(self, name, device, entry, unique_id): + _device: PhilipsEyecare + + def __init__( + self, + name: str, + device: PhilipsEyecare, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the light device.""" name = f"{name} Ambient Light" if unique_id is not None: @@ -775,7 +799,7 @@ class XiaomiPhilipsEyecareLampAmbientLight(XiaomiPhilipsAbstractLight): ) if result: - self._brightness = brightness + self._attr_brightness = brightness else: await self._try_command( "Turning the ambient light on failed.", self._device.ambient_on @@ -792,30 +816,36 @@ class XiaomiPhilipsEyecareLampAmbientLight(XiaomiPhilipsAbstractLight): try: state = await self.hass.async_add_executor_job(self._device.status) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) return _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.ambient - self._brightness = ceil((255 / 100.0) * state.ambient_brightness) + self._attr_available = True + self._attr_is_on = state.ambient + self._attr_brightness = ceil((255 / 100.0) * state.ambient_brightness) class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): """Representation of a Xiaomi Philips Zhirui Bedside Lamp.""" _attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} + _device: PhilipsMoonlight - def __init__(self, name, device, entry, unique_id): + def __init__( + self, + name: str, + device: PhilipsMoonlight, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the light device.""" super().__init__(name, device, entry, unique_id) - self._hs_color = None - self._state_attrs.pop(ATTR_DELAYED_TURN_OFF) - self._state_attrs.update( + self._attr_extra_state_attributes.pop(ATTR_DELAYED_TURN_OFF) + self._attr_extra_state_attributes.update( { ATTR_SLEEP_ASSISTANT: None, ATTR_SLEEP_OFF_TIME: None, @@ -836,12 +866,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): return 588 @property - def hs_color(self) -> tuple[float, float] | None: - """Return the hs color value.""" - return self._hs_color - - @property - def color_mode(self): + def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self.hs_color: return ColorMode.HS @@ -881,8 +906,8 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): ) if result: - self._hs_color = hs_color - self._brightness = brightness + self._attr_hs_color = hs_color + self._attr_brightness = brightness elif ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( @@ -905,7 +930,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): if result: self._color_temp = color_temp - self._brightness = brightness + self._attr_brightness = brightness elif ATTR_HS_COLOR in kwargs: _LOGGER.debug("Setting color: %s", rgb) @@ -915,7 +940,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): ) if result: - self._hs_color = hs_color + self._attr_hs_color = hs_color elif ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( @@ -946,7 +971,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): ) if result: - self._brightness = brightness + self._attr_brightness = brightness else: await self._try_command("Turning the light on failed.", self._device.on) @@ -956,16 +981,16 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): try: state = await self.hass.async_add_executor_job(self._device.status) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) return _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) + self._attr_available = True + self._attr_is_on = state.is_on + self._attr_brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( state.color_temperature, CCT_MIN, @@ -973,9 +998,9 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): self._max_mireds, self._min_mireds, ) - self._hs_color = color_util.color_RGB_to_hs(*state.rgb) + self._attr_hs_color = color_util.color_RGB_to_hs(*state.rgb) - self._state_attrs.update( + self._attr_extra_state_attributes.update( { ATTR_SCENE: state.scene, ATTR_SLEEP_ASSISTANT: state.sleep_assistant, @@ -1000,20 +1025,14 @@ class XiaomiGatewayLight(LightEntity): def __init__(self, gateway_device, gateway_name, gateway_device_id): """Initialize the XiaomiGatewayLight.""" self._gateway = gateway_device - self._name = f"{gateway_name} Light" + self._attr_name = f"{gateway_name} Light" self._gateway_device_id = gateway_device_id - self._unique_id = gateway_device_id - self._available = False - self._is_on = None + self._attr_unique_id = gateway_device_id + self._attr_available = False self._brightness_pct = 100 self._rgb = (255, 255, 255) self._hs = (0, 0) - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - @property def device_info(self) -> DeviceInfo: """Return the device info of the gateway.""" @@ -1021,21 +1040,6 @@ class XiaomiGatewayLight(LightEntity): identifiers={(DOMAIN, self._gateway_device_id)}, ) - @property - def name(self): - """Return the name of this entity, if any.""" - return self._name - - @property - def available(self): - """Return true when state is known.""" - return self._available - - @property - def is_on(self): - """Return true if it is on.""" - return self._is_on - @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -1074,17 +1078,17 @@ class XiaomiGatewayLight(LightEntity): self._gateway.light.rgb_status ) except GatewayException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error( "Got exception while fetching the gateway light state: %s", ex ) return - self._available = True - self._is_on = state_dict["is_on"] + self._attr_available = True + self._attr_is_on = state_dict["is_on"] - if self._is_on: + if self._attr_is_on: self._brightness_pct = state_dict["brightness"] self._rgb = state_dict["rgb"] self._hs = color_util.color_RGB_to_hs(*self._rgb) @@ -1095,6 +1099,7 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): _attr_color_mode = ColorMode.COLOR_TEMP _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + _sub_device: LightBulb @property def brightness(self): @@ -1107,7 +1112,7 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): return self._sub_device.status["color_temp"] @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" return self._sub_device.status["status"] == "on" diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index f30d4728275..2f7066c6fdf 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -4,15 +4,15 @@ from __future__ import annotations import dataclasses from dataclasses import dataclass +from typing import Any -from miio import Device +from miio import Device as MiioDevice from homeassistant.components.number import ( DOMAIN as PLATFORM_DOMAIN, NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE, CONF_MODEL, @@ -61,8 +61,6 @@ from .const import ( FEATURE_SET_MOTOR_SPEED, FEATURE_SET_OSCILLATION_ANGLE, FEATURE_SET_VOLUME, - KEY_COORDINATOR, - KEY_DEVICE, MODEL_AIRFRESH_A1, MODEL_AIRFRESH_T2017, MODEL_AIRFRESH_VA2, @@ -99,6 +97,7 @@ from .const import ( MODELS_PURIFIER_MIOT, ) from .entity import XiaomiCoordinatedMiioEntity +from .typing import XiaomiMiioConfigEntry ATTR_DELAY_OFF_COUNTDOWN = "delay_off_countdown" ATTR_FAN_LEVEL = "fan_level" @@ -288,7 +287,7 @@ FAVORITE_LEVEL_VALUES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Selectors from a config entry.""" @@ -296,7 +295,8 @@ async def async_setup_entry( if config_entry.data[CONF_FLOW_TYPE] != CONF_DEVICE: return model = config_entry.data[CONF_MODEL] - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + device = config_entry.runtime_data.device + coordinator = config_entry.runtime_data.device_coordinator if model in MODEL_TO_FEATURES_MAP: features = MODEL_TO_FEATURES_MAP[model] @@ -343,7 +343,7 @@ async def async_setup_entry( device, config_entry, f"{description.key}_{config_entry.unique_id}", - hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + coordinator, description, ) ) @@ -351,17 +351,19 @@ async def async_setup_entry( async_add_entities(entities) -class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): +class XiaomiNumberEntity( + XiaomiCoordinatedMiioEntity[DataUpdateCoordinator[Any]], NumberEntity +): """Representation of a generic Xiaomi attribute selector.""" entity_description: XiaomiMiioNumberDescription def __init__( self, - device: Device, - entry: ConfigEntry, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, unique_id: str, - coordinator: DataUpdateCoordinator, + coordinator: DataUpdateCoordinator[Any], description: XiaomiMiioNumberDescription, ) -> None: """Initialize the generic Xiaomi attribute selector.""" @@ -403,7 +405,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Set the target motor speed.""" return await self._try_command( "Setting the target motor speed of the miio device failed.", - self._device.set_speed, + self._device.set_speed, # type: ignore[attr-defined] motor_speed, ) @@ -411,7 +413,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Set the favorite level.""" return await self._try_command( "Setting the favorite level of the miio device failed.", - self._device.set_favorite_level, + self._device.set_favorite_level, # type: ignore[attr-defined] level, ) @@ -419,7 +421,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Set the fan level.""" return await self._try_command( "Setting the fan level of the miio device failed.", - self._device.set_fan_level, + self._device.set_fan_level, # type: ignore[attr-defined] level, ) @@ -427,21 +429,23 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Set the volume.""" return await self._try_command( "Setting the volume of the miio device failed.", - self._device.set_volume, + self._device.set_volume, # type: ignore[attr-defined] volume, ) async def async_set_oscillation_angle(self, angle: int) -> bool: """Set the volume.""" return await self._try_command( - "Setting angle of the miio device failed.", self._device.set_angle, angle + "Setting angle of the miio device failed.", + self._device.set_angle, # type: ignore[attr-defined] + angle, ) async def async_set_delay_off_countdown(self, delay_off_countdown: int) -> bool: """Set the delay off countdown.""" return await self._try_command( "Setting delay off miio device failed.", - self._device.delay_off, + self._device.delay_off, # type: ignore[attr-defined] delay_off_countdown, ) @@ -449,7 +453,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Set the led brightness level.""" return await self._try_command( "Setting the led brightness level of the miio device failed.", - self._device.set_led_brightness_level, + self._device.set_led_brightness_level, # type: ignore[attr-defined] level, ) @@ -457,7 +461,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Set the led brightness level.""" return await self._try_command( "Setting the led brightness level of the miio device failed.", - self._device.set_led_brightness, + self._device.set_led_brightness, # type: ignore[attr-defined] level, ) @@ -465,6 +469,6 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Set the target motor speed.""" return await self._try_command( "Setting the favorite rpm of the miio device failed.", - self._device.set_favorite_rpm, + self._device.set_favorite_rpm, # type: ignore[attr-defined] rpm, ) diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py index 9c83f3f4674..b5c7fa8710a 100644 --- a/homeassistant/components/xiaomi_miio/remote.py +++ b/homeassistant/components/xiaomi_miio/remote.py @@ -187,24 +187,14 @@ class XiaomiMiioRemote(RemoteEntity): def __init__(self, friendly_name, device, unique_id, slot, timeout, commands): """Initialize the remote.""" - self._name = friendly_name + self._attr_name = friendly_name self._device = device - self._unique_id = unique_id + self._attr_unique_id = unique_id self._slot = slot self._timeout = timeout self._state = False self._commands = commands - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of the remote.""" - return self._name - @property def device(self): """Return the remote object.""" diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 94a93fc1fae..6dff7cf8ede 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -4,8 +4,9 @@ from __future__ import annotations from dataclasses import dataclass, field import logging -from typing import NamedTuple +from typing import Any, NamedTuple +from miio import Device as MiioDevice from miio.fan_common import LedBrightness as FanLedBrightness from miio.integrations.airpurifier.dmaker.airfresh_t2017 import ( DisplayOrientation as AirfreshT2017DisplayOrientation, @@ -29,16 +30,13 @@ from miio.integrations.humidifier.zhimi.airhumidifier_miot import ( ) from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_FLOW_TYPE, - DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE, MODEL_AIRFRESH_T2017, MODEL_AIRFRESH_VA2, MODEL_AIRFRESH_VA4, @@ -64,6 +62,7 @@ from .const import ( MODEL_FAN_ZA4, ) from .entity import XiaomiCoordinatedMiioEntity +from .typing import XiaomiMiioConfigEntry ATTR_DISPLAY_ORIENTATION = "display_orientation" ATTR_LED_BRIGHTNESS = "led_brightness" @@ -90,7 +89,7 @@ class AttributeEnumMapping(NamedTuple): enum_class: type -MODEL_TO_ATTR_MAP: dict[str, list] = { +MODEL_TO_ATTR_MAP: dict[str, list[AttributeEnumMapping]] = { MODEL_AIRFRESH_T2017: [ AttributeEnumMapping(ATTR_DISPLAY_ORIENTATION, AirfreshT2017DisplayOrientation), AttributeEnumMapping(ATTR_PTC_LEVEL, AirfreshT2017PtcLevel), @@ -204,7 +203,7 @@ SELECTOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Selectors from a config entry.""" @@ -216,8 +215,8 @@ async def async_setup_entry( return unique_id = config_entry.unique_id - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + device = config_entry.runtime_data.device + coordinator = config_entry.runtime_data.device_coordinator attributes = MODEL_TO_ATTR_MAP[model] async_add_entities( @@ -235,10 +234,21 @@ async def async_setup_entry( ) -class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity): +class XiaomiSelector( + XiaomiCoordinatedMiioEntity[DataUpdateCoordinator[Any]], SelectEntity +): """Representation of a generic Xiaomi attribute selector.""" - def __init__(self, device, entry, unique_id, coordinator, description): + entity_description: XiaomiMiioSelectDescription + + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str, + coordinator: DataUpdateCoordinator[Any], + description: XiaomiMiioSelectDescription, + ) -> None: """Initialize the generic Xiaomi attribute selector.""" super().__init__(device, entry, unique_id, coordinator) self.entity_description = description @@ -247,9 +257,15 @@ class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity): class XiaomiGenericSelector(XiaomiSelector): """Representation of a Xiaomi generic selector.""" - entity_description: XiaomiMiioSelectDescription - - def __init__(self, device, entry, unique_id, coordinator, description, enum_class): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str, + coordinator: DataUpdateCoordinator[Any], + description: XiaomiMiioSelectDescription, + enum_class: type, + ) -> None: """Initialize the generic Xiaomi attribute selector.""" super().__init__(device, entry, unique_id, coordinator, description) self._current_attr = enum_class( @@ -260,10 +276,10 @@ class XiaomiGenericSelector(XiaomiSelector): if description.options_map: self._options_map = {} - for key, val in enum_class._member_map_.items(): + for key, val in enum_class._member_map_.items(): # type: ignore[attr-defined] self._options_map[description.options_map[key]] = val else: - self._options_map = enum_class._member_map_ + self._options_map = enum_class._member_map_ # type: ignore[attr-defined] self._reverse_map = {val: key for key, val in self._options_map.items()} self._enum_class = enum_class diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 6f623c46af8..eb630e6d28f 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -5,8 +5,10 @@ from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass import logging +from typing import TYPE_CHECKING, Any -from miio import AirQualityMonitor, DeviceException +from miio import AirQualityMonitor, Device as MiioDevice, DeviceException +from miio.gateway.devices import SubDevice from miio.gateway.gateway import ( GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, @@ -22,7 +24,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, @@ -46,6 +47,7 @@ from homeassistant.const import ( 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 DataUpdateCoordinator from homeassistant.util import dt as dt_util from . import VacuumCoordinatorDataAttributes @@ -53,8 +55,6 @@ from .const import ( CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE, MODEL_AIRFRESH_A1, MODEL_AIRFRESH_T2017, MODEL_AIRFRESH_VA2, @@ -91,6 +91,7 @@ from .const import ( ROCKROBO_GENERIC, ) from .entity import XiaomiCoordinatedMiioEntity, XiaomiGatewayDevice, XiaomiMiioEntity +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -724,13 +725,19 @@ VACUUM_SENSORS = { } -def _setup_vacuum_sensors(hass, config_entry, async_add_entities): +def _setup_vacuum_sensors( + hass: HomeAssistant, + config_entry: XiaomiMiioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Set up the Xiaomi vacuum sensors.""" - device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE) - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + device = config_entry.runtime_data.device + coordinator = config_entry.runtime_data.device_coordinator entities = [] for sensor, description in VACUUM_SENSORS.items(): + if TYPE_CHECKING: + assert description.parent_key is not None parent_key_data = getattr(coordinator.data, description.parent_key) if getattr(parent_key_data, description.key, None) is None: _LOGGER.debug( @@ -754,14 +761,14 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi sensor from a config entry.""" entities: list[SensorEntity] = [] if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: - gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] + gateway = config_entry.runtime_data.gateway # Gateway illuminance sensor if gateway.model not in [ GATEWAY_MODEL_AC_V1, @@ -779,9 +786,7 @@ async def async_setup_entry( # Gateway sub devices sub_devices = gateway.devices for sub_device in sub_devices.values(): - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR][ - sub_device.sid - ] + coordinator = config_entry.runtime_data.gateway_coordinators[sub_device.sid] for sensor, description in SENSOR_TYPES.items(): if sensor not in sub_device.status: continue @@ -791,6 +796,7 @@ async def async_setup_entry( ) ) elif config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + device: MiioDevice host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] model: str = config_entry.data[CONF_MODEL] @@ -811,7 +817,8 @@ async def async_setup_entry( ) ) else: - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + device = config_entry.runtime_data.device + coordinator = config_entry.runtime_data.device_coordinator sensors: Iterable[str] = [] if model in MODEL_TO_SENSORS_MAP: sensors = MODEL_TO_SENSORS_MAP[model] @@ -839,7 +846,7 @@ async def async_setup_entry( device, config_entry, f"{sensor}_{config_entry.unique_id}", - hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + coordinator, description, ) ) @@ -847,12 +854,21 @@ async def async_setup_entry( async_add_entities(entities) -class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): +class XiaomiGenericSensor( + XiaomiCoordinatedMiioEntity[DataUpdateCoordinator[Any]], SensorEntity +): """Representation of a Xiaomi generic sensor.""" entity_description: XiaomiMiioSensorDescription - def __init__(self, device, entry, unique_id, coordinator, description): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str, + coordinator: DataUpdateCoordinator[Any], + description: XiaomiMiioSensorDescription, + ) -> None: """Initialize the entity.""" super().__init__(device, entry, unique_id, coordinator) self.entity_description = description @@ -909,13 +925,20 @@ class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): """Representation of a Xiaomi Air Quality Monitor.""" - def __init__(self, name, device, entry, unique_id, description): + _device: AirQualityMonitor + + def __init__( + self, + name: str, + device: AirQualityMonitor, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + description: XiaomiMiioSensorDescription, + ) -> None: """Initialize the entity.""" super().__init__(name, device, entry, unique_id) - self._available = None - self._state = None - self._state_attrs = { + self._attr_extra_state_attributes = { ATTR_POWER: None, ATTR_BATTERY_LEVEL: None, ATTR_CHARGING: None, @@ -927,30 +950,15 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): } self.entity_description = description - @property - def available(self): - """Return true when state is known.""" - return self._available - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - return self._state_attrs - async def async_update(self) -> None: """Fetch state from the miio device.""" try: state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.aqi - self._state_attrs.update( + self._attr_available = True + self._attr_native_value = state.aqi + self._attr_extra_state_attributes.update( { ATTR_POWER: state.power, ATTR_CHARGING: state.usb_power, @@ -964,19 +972,25 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): ) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) class XiaomiGatewaySensor(XiaomiGatewayDevice, SensorEntity): """Representation of a XiaomiGatewaySensor.""" - def __init__(self, coordinator, sub_device, entry, description): + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, bool]], + sub_device: SubDevice, + entry: XiaomiMiioConfigEntry, + description: XiaomiMiioSensorDescription, + ) -> None: """Initialize the XiaomiSensor.""" super().__init__(coordinator, sub_device, entry) - self._unique_id = f"{sub_device.sid}-{description.key}" - self._name = f"{description.key} ({sub_device.sid})".capitalize() + self._attr_unique_id = f"{sub_device.sid}-{description.key}" + self._attr_name = f"{description.key} ({sub_device.sid})".capitalize() self.entity_description = description @property @@ -997,29 +1011,18 @@ class XiaomiGatewayIlluminanceSensor(SensorEntity): ) self._gateway = gateway_device self.entity_description = description - self._available = False - self._state = None - - @property - def available(self): - """Return true when state is known.""" - return self._available - - @property - def native_value(self): - """Return the state of the device.""" - return self._state + self._attr_available = False async def async_update(self) -> None: """Fetch state from the device.""" try: - self._state = await self.hass.async_add_executor_job( + self._attr_native_value = await self.hass.async_add_executor_job( self._gateway.get_illumination ) - self._available = True + self._attr_available = True except GatewayException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error( "Got exception while fetching the gateway illuminance state: %s", ex ) diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index e4b94aebc20..0f78e67d30c 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -8,7 +8,15 @@ from functools import partial import logging from typing import Any -from miio import AirConditioningCompanionV3, ChuangmiPlug, DeviceException, PowerStrip +from miio import ( + AirConditioningCompanionV3, + ChuangmiPlug, + Device as MiioDevice, + DeviceException, + PowerStrip, +) +from miio.gateway.devices import SubDevice +from miio.gateway.devices.switch import Switch from miio.powerstrip import PowerMode import voluptuous as vol @@ -17,7 +25,6 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, @@ -31,6 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_FLOW_TYPE, @@ -72,8 +80,6 @@ from .const import ( FEATURE_SET_LEARN_MODE, FEATURE_SET_LED, FEATURE_SET_PTC, - KEY_COORDINATOR, - KEY_DEVICE, MODEL_AIRFRESH_A1, MODEL_AIRFRESH_T2017, MODEL_AIRFRESH_VA2, @@ -116,7 +122,7 @@ from .const import ( SUCCESS, ) from .entity import XiaomiCoordinatedMiioEntity, XiaomiGatewayDevice, XiaomiMiioEntity -from .typing import ServiceMethodDetails +from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -340,7 +346,7 @@ SWITCH_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch from a config entry.""" @@ -351,12 +357,16 @@ async def async_setup_entry( await async_setup_other_entry(hass, config_entry, async_add_entities) -async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): +async def async_setup_coordinated_entry( + hass: HomeAssistant, + config_entry: XiaomiMiioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Set up the coordinated switch from a config entry.""" model = config_entry.data[CONF_MODEL] unique_id = config_entry.unique_id - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + device = config_entry.runtime_data.device + coordinator = config_entry.runtime_data.device_coordinator if DATA_KEY not in hass.data: hass.data[DATA_KEY] = {} @@ -387,24 +397,26 @@ async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): ) -async def async_setup_other_entry(hass, config_entry, async_add_entities): +async def async_setup_other_entry( + hass: HomeAssistant, + config_entry: XiaomiMiioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Set up the other type switch from a config entry.""" - entities = [] + entities: list[SwitchEntity] = [] host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] name = config_entry.title model = config_entry.data[CONF_MODEL] unique_id = config_entry.unique_id if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: - gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] + gateway = config_entry.runtime_data.gateway # Gateway sub devices sub_devices = gateway.devices for sub_device in sub_devices.values(): if sub_device.device_type != "Switch": continue - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR][ - sub_device.sid - ] + coordinator = config_entry.runtime_data.gateway_coordinators[sub_device.sid] switch_variables = set(sub_device.status) & set(GATEWAY_SWITCH_VARS) if switch_variables: entities.extend( @@ -420,13 +432,14 @@ async def async_setup_other_entry(hass, config_entry, async_add_entities): config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY and model == "lumi.acpartner.v3" ): + device: SwitchEntity if DATA_KEY not in hass.data: hass.data[DATA_KEY] = {} _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]: - plug = ChuangmiPlug(host, token, model=model) + chuangmi_plug = ChuangmiPlug(host, token, model=model) # The device has two switchable channels (mains and a USB port). # A switch device per channel will be created. @@ -436,13 +449,13 @@ async def async_setup_other_entry(hass, config_entry, async_add_entities): else: unique_id_ch = f"{unique_id}-mains" device = ChuangMiPlugSwitch( - name, plug, config_entry, unique_id_ch, channel_usb + name, chuangmi_plug, config_entry, unique_id_ch, channel_usb ) entities.append(device) hass.data[DATA_KEY][host] = device elif model in ["qmi.powerstrip.v1", "zimi.powerstrip.v2"]: - plug = PowerStrip(host, token, model=model) - device = XiaomiPowerStripSwitch(name, plug, config_entry, unique_id) + power_strip = PowerStrip(host, token, model=model) + device = XiaomiPowerStripSwitch(name, power_strip, config_entry, unique_id) entities.append(device) hass.data[DATA_KEY][host] = device elif model in [ @@ -452,14 +465,16 @@ async def async_setup_other_entry(hass, config_entry, async_add_entities): "chuangmi.plug.hmi205", "chuangmi.plug.hmi206", ]: - plug = ChuangmiPlug(host, token, model=model) - device = XiaomiPlugGenericSwitch(name, plug, config_entry, unique_id) + chuangmi_plug = ChuangmiPlug(host, token, model=model) + device = XiaomiPlugGenericSwitch( + name, chuangmi_plug, config_entry, unique_id + ) entities.append(device) hass.data[DATA_KEY][host] = device elif model in ["lumi.acpartner.v3"]: - plug = AirConditioningCompanionV3(host, token) + ac_companion = AirConditioningCompanionV3(host, token) device = XiaomiAirConditioningCompanionSwitch( - name, plug, config_entry, unique_id + name, ac_companion, config_entry, unique_id ) entities.append(device) hass.data[DATA_KEY][host] = device @@ -511,12 +526,21 @@ async def async_setup_other_entry(hass, config_entry, async_add_entities): async_add_entities(entities) -class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): +class XiaomiGenericCoordinatedSwitch( + XiaomiCoordinatedMiioEntity[DataUpdateCoordinator[Any]], SwitchEntity +): """Representation of a Xiaomi Plug Generic.""" entity_description: XiaomiMiioSwitchDescription - def __init__(self, device, entry, unique_id, coordinator, description): + def __init__( + self, + device: MiioDevice, + entry: XiaomiMiioConfigEntry, + unique_id: str, + coordinator: DataUpdateCoordinator[Any], + description: XiaomiMiioSwitchDescription, + ) -> None: """Initialize the plug switch.""" super().__init__(device, entry, unique_id, coordinator) @@ -565,7 +589,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the buzzer on.""" return await self._try_command( "Turning the buzzer of the miio device on failed.", - self._device.set_buzzer, + self._device.set_buzzer, # type: ignore[attr-defined] True, ) @@ -573,7 +597,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the buzzer off.""" return await self._try_command( "Turning the buzzer of the miio device off failed.", - self._device.set_buzzer, + self._device.set_buzzer, # type: ignore[attr-defined] False, ) @@ -581,7 +605,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the child lock on.""" return await self._try_command( "Turning the child lock of the miio device on failed.", - self._device.set_child_lock, + self._device.set_child_lock, # type: ignore[attr-defined] True, ) @@ -589,7 +613,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the child lock off.""" return await self._try_command( "Turning the child lock of the miio device off failed.", - self._device.set_child_lock, + self._device.set_child_lock, # type: ignore[attr-defined] False, ) @@ -597,7 +621,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the display on.""" return await self._try_command( "Turning the display of the miio device on failed.", - self._device.set_display, + self._device.set_display, # type: ignore[attr-defined] True, ) @@ -605,7 +629,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the display off.""" return await self._try_command( "Turning the display of the miio device off failed.", - self._device.set_display, + self._device.set_display, # type: ignore[attr-defined] False, ) @@ -613,7 +637,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the dry mode on.""" return await self._try_command( "Turning the dry mode of the miio device on failed.", - self._device.set_dry, + self._device.set_dry, # type: ignore[attr-defined] True, ) @@ -621,7 +645,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the dry mode off.""" return await self._try_command( "Turning the dry mode of the miio device off failed.", - self._device.set_dry, + self._device.set_dry, # type: ignore[attr-defined] False, ) @@ -629,7 +653,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the dry mode on.""" return await self._try_command( "Turning the clean mode of the miio device on failed.", - self._device.set_clean_mode, + self._device.set_clean_mode, # type: ignore[attr-defined] True, ) @@ -637,7 +661,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the dry mode off.""" return await self._try_command( "Turning the clean mode of the miio device off failed.", - self._device.set_clean_mode, + self._device.set_clean_mode, # type: ignore[attr-defined] False, ) @@ -645,7 +669,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the led on.""" return await self._try_command( "Turning the led of the miio device on failed.", - self._device.set_led, + self._device.set_led, # type: ignore[attr-defined] True, ) @@ -653,7 +677,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the led off.""" return await self._try_command( "Turning the led of the miio device off failed.", - self._device.set_led, + self._device.set_led, # type: ignore[attr-defined] False, ) @@ -661,7 +685,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the learn mode on.""" return await self._try_command( "Turning the learn mode of the miio device on failed.", - self._device.set_learn_mode, + self._device.set_learn_mode, # type: ignore[attr-defined] True, ) @@ -669,7 +693,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn the learn mode off.""" return await self._try_command( "Turning the learn mode of the miio device off failed.", - self._device.set_learn_mode, + self._device.set_learn_mode, # type: ignore[attr-defined] False, ) @@ -677,7 +701,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn auto detect on.""" return await self._try_command( "Turning auto detect of the miio device on failed.", - self._device.set_auto_detect, + self._device.set_auto_detect, # type: ignore[attr-defined] True, ) @@ -685,7 +709,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn auto detect off.""" return await self._try_command( "Turning auto detect of the miio device off failed.", - self._device.set_auto_detect, + self._device.set_auto_detect, # type: ignore[attr-defined] False, ) @@ -693,7 +717,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn ionizer on.""" return await self._try_command( "Turning ionizer of the miio device on failed.", - self._device.set_ionizer, + self._device.set_ionizer, # type: ignore[attr-defined] True, ) @@ -701,7 +725,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn ionizer off.""" return await self._try_command( "Turning ionizer of the miio device off failed.", - self._device.set_ionizer, + self._device.set_ionizer, # type: ignore[attr-defined] False, ) @@ -709,7 +733,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn ionizer on.""" return await self._try_command( "Turning ionizer of the miio device on failed.", - self._device.set_anion, + self._device.set_anion, # type: ignore[attr-defined] True, ) @@ -717,7 +741,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn ionizer off.""" return await self._try_command( "Turning ionizer of the miio device off failed.", - self._device.set_anion, + self._device.set_anion, # type: ignore[attr-defined] False, ) @@ -725,7 +749,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn ionizer on.""" return await self._try_command( "Turning ionizer of the miio device on failed.", - self._device.set_ptc, + self._device.set_ptc, # type: ignore[attr-defined] True, ) @@ -733,7 +757,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Turn ionizer off.""" return await self._try_command( "Turning ionizer of the miio device off failed.", - self._device.set_ptc, + self._device.set_ptc, # type: ignore[attr-defined] False, ) @@ -742,17 +766,24 @@ class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): """Representation of a XiaomiGatewaySwitch.""" _attr_device_class = SwitchDeviceClass.SWITCH + _sub_device: Switch - def __init__(self, coordinator, sub_device, entry, variable): + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, bool]], + sub_device: SubDevice, + entry: XiaomiMiioConfigEntry, + variable: str, + ) -> None: """Initialize the XiaomiSensor.""" super().__init__(coordinator, sub_device, entry) self._channel = GATEWAY_SWITCH_VARS[variable][KEY_CHANNEL] self._data_key = f"status_ch{self._channel}" - self._unique_id = f"{sub_device.sid}-ch{self._channel}" - self._name = f"{sub_device.name} ch{self._channel} ({sub_device.sid})" + self._attr_unique_id = f"{sub_device.sid}-ch{self._channel}" + self._attr_name = f"{sub_device.name} ch{self._channel} ({sub_device.sid})" @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self._sub_device.status[self._data_key] == "on" @@ -772,37 +803,26 @@ class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): """Representation of a Xiaomi Plug Generic.""" - def __init__(self, name, device, entry, unique_id): + _attr_icon = "mdi:power-socket" + _device: AirConditioningCompanionV3 | ChuangmiPlug | PowerStrip + + def __init__( + self, + name: str, + device: AirConditioningCompanionV3 | ChuangmiPlug | PowerStrip, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the plug switch.""" super().__init__(name, device, entry, unique_id) - self._icon = "mdi:power-socket" - self._available = False - self._state = None - self._state_attrs = {ATTR_TEMPERATURE: None, ATTR_MODEL: self._model} + self._attr_extra_state_attributes = { + ATTR_TEMPERATURE: None, + ATTR_MODEL: self._model, + } self._device_features = FEATURE_FLAGS_GENERIC self._skip_update = False - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def available(self): - """Return true when state is known.""" - return self._available - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - return self._state_attrs - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state - async def _try_command(self, mask_error, func, *args, **kwargs): """Call a plug command handling error messages.""" try: @@ -810,9 +830,9 @@ class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): partial(func, *args, **kwargs) ) except DeviceException as exc: - if self._available: + if self._attr_available: _LOGGER.error(mask_error, exc) - self._available = False + self._attr_available = False return False @@ -829,7 +849,7 @@ class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): result = await self._try_command("Turning the plug on failed", self._device.on) if result: - self._state = True + self._attr_is_on = True self._skip_update = True async def async_turn_off(self, **kwargs: Any) -> None: @@ -839,7 +859,7 @@ class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): ) if result: - self._state = False + self._attr_is_on = False self._skip_update = True async def async_update(self) -> None: @@ -853,13 +873,13 @@ class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.is_on - self._state_attrs[ATTR_TEMPERATURE] = state.temperature + self._attr_available = True + self._attr_is_on = state.is_on + self._attr_extra_state_attributes[ATTR_TEMPERATURE] = state.temperature except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) async def async_set_wifi_led_on(self): @@ -887,7 +907,7 @@ class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): await self._try_command( "Setting the power price of the power strip failed", - self._device.set_power_price, + self._device.set_power_price, # type: ignore[union-attr] price, ) @@ -895,25 +915,33 @@ class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): """Representation of a Xiaomi Power Strip.""" - def __init__(self, name, plug, model, unique_id): + _device: PowerStrip + + def __init__( + self, + name: str, + plug: PowerStrip, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: """Initialize the plug switch.""" - super().__init__(name, plug, model, unique_id) + super().__init__(name, plug, entry, unique_id) if self._model == MODEL_POWER_STRIP_V2: self._device_features = FEATURE_FLAGS_POWER_STRIP_V2 else: self._device_features = FEATURE_FLAGS_POWER_STRIP_V1 - self._state_attrs[ATTR_LOAD_POWER] = None + self._attr_extra_state_attributes[ATTR_LOAD_POWER] = None if self._device_features & FEATURE_SET_POWER_MODE == 1: - self._state_attrs[ATTR_POWER_MODE] = None + self._attr_extra_state_attributes[ATTR_POWER_MODE] = None if self._device_features & FEATURE_SET_WIFI_LED == 1: - self._state_attrs[ATTR_WIFI_LED] = None + self._attr_extra_state_attributes[ATTR_WIFI_LED] = None if self._device_features & FEATURE_SET_POWER_PRICE == 1: - self._state_attrs[ATTR_POWER_PRICE] = None + self._attr_extra_state_attributes[ATTR_POWER_PRICE] = None async def async_update(self) -> None: """Fetch state from the device.""" @@ -926,27 +954,27 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.is_on - self._state_attrs.update( + self._attr_available = True + self._attr_is_on = state.is_on + self._attr_extra_state_attributes.update( {ATTR_TEMPERATURE: state.temperature, ATTR_LOAD_POWER: state.load_power} ) if self._device_features & FEATURE_SET_POWER_MODE == 1 and state.mode: - self._state_attrs[ATTR_POWER_MODE] = state.mode.value + self._attr_extra_state_attributes[ATTR_POWER_MODE] = state.mode.value if self._device_features & FEATURE_SET_WIFI_LED == 1 and state.wifi_led: - self._state_attrs[ATTR_WIFI_LED] = state.wifi_led + self._attr_extra_state_attributes[ATTR_WIFI_LED] = state.wifi_led if ( self._device_features & FEATURE_SET_POWER_PRICE == 1 and state.power_price ): - self._state_attrs[ATTR_POWER_PRICE] = state.power_price + self._attr_extra_state_attributes[ATTR_POWER_PRICE] = state.power_price except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) async def async_set_power_mode(self, mode: str): @@ -964,7 +992,16 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): """Representation of a Chuang Mi Plug V1 and V3.""" - def __init__(self, name, plug, entry, unique_id, channel_usb): + _device: ChuangmiPlug + + def __init__( + self, + name: str, + plug: ChuangmiPlug, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + channel_usb: bool, + ) -> None: """Initialize the plug switch.""" name = f"{name} USB" if channel_usb else name @@ -976,30 +1013,33 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): if self._model == MODEL_PLUG_V3: self._device_features = FEATURE_FLAGS_PLUG_V3 - self._state_attrs[ATTR_WIFI_LED] = None + self._attr_extra_state_attributes[ATTR_WIFI_LED] = None if self._channel_usb is False: - self._state_attrs[ATTR_LOAD_POWER] = None + self._attr_extra_state_attributes[ATTR_LOAD_POWER] = None async def async_turn_on(self, **kwargs: Any) -> None: """Turn a channel on.""" if self._channel_usb: result = await self._try_command( - "Turning the plug on failed", self._device.usb_on + "Turning the plug on failed", + self._device.usb_on, ) else: result = await self._try_command( - "Turning the plug on failed", self._device.on + "Turning the plug on failed", + self._device.on, ) if result: - self._state = True + self._attr_is_on = True self._skip_update = True async def async_turn_off(self, **kwargs: Any) -> None: """Turn a channel off.""" if self._channel_usb: result = await self._try_command( - "Turning the plug off failed", self._device.usb_off + "Turning the plug off failed", + self._device.usb_off, ) else: result = await self._try_command( @@ -1007,7 +1047,7 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): ) if result: - self._state = False + self._attr_is_on = False self._skip_update = True async def async_update(self) -> None: @@ -1021,53 +1061,65 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) - self._available = True + self._attr_available = True if self._channel_usb: - self._state = state.usb_power + self._attr_is_on = state.usb_power else: - self._state = state.is_on + self._attr_is_on = state.is_on - self._state_attrs[ATTR_TEMPERATURE] = state.temperature + self._attr_extra_state_attributes[ATTR_TEMPERATURE] = state.temperature if state.wifi_led: - self._state_attrs[ATTR_WIFI_LED] = state.wifi_led + self._attr_extra_state_attributes[ATTR_WIFI_LED] = state.wifi_led if self._channel_usb is False and state.load_power: - self._state_attrs[ATTR_LOAD_POWER] = state.load_power + self._attr_extra_state_attributes[ATTR_LOAD_POWER] = state.load_power except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): """Representation of a Xiaomi AirConditioning Companion.""" - def __init__(self, name, plug, model, unique_id): - """Initialize the acpartner switch.""" - super().__init__(name, plug, model, unique_id) + _device: AirConditioningCompanionV3 - self._state_attrs.update({ATTR_TEMPERATURE: None, ATTR_LOAD_POWER: None}) + def __init__( + self, + name: str, + plug: AirConditioningCompanionV3, + entry: XiaomiMiioConfigEntry, + unique_id: str | None, + ) -> None: + """Initialize the acpartner switch.""" + super().__init__(name, plug, entry, unique_id) + + self._attr_extra_state_attributes.update( + {ATTR_TEMPERATURE: None, ATTR_LOAD_POWER: None} + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the socket on.""" result = await self._try_command( - "Turning the socket on failed", self._device.socket_on + "Turning the socket on failed", + self._device.socket_on, ) if result: - self._state = True + self._attr_is_on = True self._skip_update = True async def async_turn_off(self, **kwargs: Any) -> None: """Turn the socket off.""" result = await self._try_command( - "Turning the socket off failed", self._device.socket_off + "Turning the socket off failed", + self._device.socket_off, ) if result: - self._state = False + self._attr_is_on = False self._skip_update = True async def async_update(self) -> None: @@ -1081,11 +1133,11 @@ class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) - self._available = True - self._state = state.power_socket == "on" - self._state_attrs[ATTR_LOAD_POWER] = state.load_power + self._attr_available = True + self._attr_is_on = state.power_socket == "on" + self._attr_extra_state_attributes[ATTR_LOAD_POWER] = state.load_power except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/xiaomi_miio/typing.py b/homeassistant/components/xiaomi_miio/typing.py index 8fbb8e3d83f..e657f58fbce 100644 --- a/homeassistant/components/xiaomi_miio/typing.py +++ b/homeassistant/components/xiaomi_miio/typing.py @@ -1,12 +1,36 @@ """Typings for the xiaomi_miio integration.""" -from typing import NamedTuple +from dataclasses import dataclass +from typing import Any, NamedTuple +from miio import Device as MiioDevice +from miio.gateway.gateway import Gateway import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + class ServiceMethodDetails(NamedTuple): """Details for SERVICE_TO_METHOD mapping.""" method: str schema: vol.Schema | None = None + + +@dataclass +class XiaomiMiioRuntimeData: + """Runtime data for Xiaomi Miio config entry. + + Either device/device_coordinator or gateway/gateway_coordinators + must be set, based on CONF_FLOW_TYPE (CONF_DEVICE or CONF_GATEWAY) + """ + + device: MiioDevice = None # type: ignore[assignment] + device_coordinator: DataUpdateCoordinator[Any] = None # type: ignore[assignment] + + gateway: Gateway = None # type: ignore[assignment] + gateway_coordinators: dict[str, DataUpdateCoordinator[dict[str, bool]]] = None # type: ignore[assignment] + + +type XiaomiMiioConfigEntry = ConfigEntry[XiaomiMiioRuntimeData] diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 1cbc79b89f3..3b397e9ccfd 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -14,7 +14,6 @@ from homeassistant.components.vacuum import ( VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform @@ -25,9 +24,6 @@ from homeassistant.util.dt import as_utc from . import VacuumCoordinatorData from .const import ( CONF_FLOW_TYPE, - DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, SERVICE_GOTO, @@ -37,6 +33,7 @@ from .const import ( SERVICE_STOP_REMOTE_CONTROL, ) from .entity import XiaomiCoordinatedMiioEntity +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -78,7 +75,7 @@ STATE_CODE_TO_STATE = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiMiioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi vacuum cleaner robot from a config entry.""" @@ -88,10 +85,10 @@ async def async_setup_entry( unique_id = config_entry.unique_id mirobo = MiroboVacuum( - hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE], + config_entry.runtime_data.device, config_entry, unique_id, - hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + config_entry.runtime_data.device_coordinator, ) entities.append(mirobo) @@ -197,17 +194,6 @@ class MiroboVacuum( | VacuumEntityFeature.START ) - def __init__( - self, - device, - entry, - unique_id, - coordinator: DataUpdateCoordinator[VacuumCoordinatorData], - ) -> None: - """Initialize the Xiaomi vacuum cleaner robot handler.""" - super().__init__(device, entry, unique_id, coordinator) - self._state: VacuumActivity | None = None - async def async_added_to_hass(self) -> None: """Run when entity is about to be added to hass.""" await super().async_added_to_hass() @@ -221,7 +207,7 @@ class MiroboVacuum( if self.coordinator.data.status.got_error: return VacuumActivity.ERROR - return self._state + return super().activity @property def battery_level(self) -> int: @@ -284,16 +270,23 @@ class MiroboVacuum( async def async_start(self) -> None: """Start or resume the cleaning task.""" await self._try_command( - "Unable to start the vacuum: %s", self._device.resume_or_start + "Unable to start the vacuum: %s", + self._device.resume_or_start, # type: ignore[attr-defined] ) async def async_pause(self) -> None: """Pause the cleaning task.""" - await self._try_command("Unable to set start/pause: %s", self._device.pause) + await self._try_command( + "Unable to set start/pause: %s", + self._device.pause, # type: ignore[attr-defined] + ) async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" - await self._try_command("Unable to stop: %s", self._device.stop) + await self._try_command( + "Unable to stop: %s", + self._device.stop, # type: ignore[attr-defined] + ) async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" @@ -310,22 +303,31 @@ class MiroboVacuum( ) return await self._try_command( - "Unable to set fan speed: %s", self._device.set_fan_speed, fan_speed_int + "Unable to set fan speed: %s", + self._device.set_fan_speed, # type: ignore[attr-defined] + fan_speed_int, ) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" - await self._try_command("Unable to return home: %s", self._device.home) + await self._try_command( + "Unable to return home: %s", + self._device.home, # type: ignore[attr-defined] + ) async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" await self._try_command( - "Unable to start the vacuum for a spot clean-up: %s", self._device.spot + "Unable to start the vacuum for a spot clean-up: %s", + self._device.spot, # type: ignore[attr-defined] ) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" - await self._try_command("Unable to locate the botvac: %s", self._device.find) + await self._try_command( + "Unable to locate the botvac: %s", + self._device.find, # type: ignore[attr-defined] + ) async def async_send_command( self, @@ -344,13 +346,15 @@ class MiroboVacuum( async def async_remote_control_start(self) -> None: """Start remote control mode.""" await self._try_command( - "Unable to start remote control the vacuum: %s", self._device.manual_start + "Unable to start remote control the vacuum: %s", + self._device.manual_start, # type: ignore[attr-defined] ) async def async_remote_control_stop(self) -> None: """Stop remote control mode.""" await self._try_command( - "Unable to stop remote control the vacuum: %s", self._device.manual_stop + "Unable to stop remote control the vacuum: %s", + self._device.manual_stop, # type: ignore[attr-defined] ) async def async_remote_control_move( @@ -359,7 +363,7 @@ class MiroboVacuum( """Move vacuum with remote control mode.""" await self._try_command( "Unable to move with remote control the vacuum: %s", - self._device.manual_control, + self._device.manual_control, # type: ignore[attr-defined] velocity=velocity, rotation=rotation, duration=duration, @@ -371,7 +375,7 @@ class MiroboVacuum( """Move vacuum one step with remote control mode.""" await self._try_command( "Unable to remote control the vacuum: %s", - self._device.manual_control_once, + self._device.manual_control_once, # type: ignore[attr-defined] velocity=velocity, rotation=rotation, duration=duration, @@ -381,7 +385,7 @@ class MiroboVacuum( """Goto the specified coordinates.""" await self._try_command( "Unable to send the vacuum cleaner to the specified coordinates: %s", - self._device.goto, + self._device.goto, # type: ignore[attr-defined] x_coord=x_coord, y_coord=y_coord, ) @@ -393,7 +397,7 @@ class MiroboVacuum( await self._try_command( "Unable to start cleaning of the specified segments: %s", - self._device.segment_clean, + self._device.segment_clean, # type: ignore[attr-defined] segments=segments, ) @@ -403,7 +407,10 @@ class MiroboVacuum( _zone.append(repeats) _LOGGER.debug("Zone with repeats: %s", zone) try: - await self.hass.async_add_executor_job(self._device.zoned_clean, zone) + await self.hass.async_add_executor_job( + self._device.zoned_clean, # type: ignore[attr-defined] + zone, + ) await self.coordinator.async_refresh() except (OSError, DeviceException) as exc: _LOGGER.error("Unable to send zoned_clean command to the vacuum: %s", exc) @@ -417,8 +424,8 @@ class MiroboVacuum( self.coordinator.data.status.state, self.coordinator.data.status.state_code, ) - self._state = None + self._attr_activity = None else: - self._state = STATE_CODE_TO_STATE[state_code] + self._attr_activity = STATE_CODE_TO_STATE[state_code] super()._handle_coordinator_update() diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py index 3bb80df25b2..0747b2130bd 100644 --- a/homeassistant/components/xs1/climate.py +++ b/homeassistant/components/xs1/climate.py @@ -5,6 +5,8 @@ from __future__ import annotations from typing import Any from xs1_api_client.api_constants import ActuatorType +from xs1_api_client.device.actuator import XS1Actuator +from xs1_api_client.device.sensor import XS1Sensor from homeassistant.components.climate import ( ClimateEntity, @@ -16,12 +18,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS +from . import ACTUATORS, DOMAIN, SENSORS from .entity import XS1DeviceEntity -MIN_TEMP = 8 -MAX_TEMP = 25 - def setup_platform( hass: HomeAssistant, @@ -30,8 +29,8 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the XS1 thermostat platform.""" - actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS] - sensors = hass.data[COMPONENT_DOMAIN][SENSORS] + actuators: list[XS1Actuator] = hass.data[DOMAIN][ACTUATORS] + sensors: list[XS1Sensor] = hass.data[DOMAIN][SENSORS] thermostat_entities = [] for actuator in actuators: @@ -56,19 +55,21 @@ class XS1ThermostatEntity(XS1DeviceEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_min_temp = 8 + _attr_max_temp = 25 - def __init__(self, device, sensor): + def __init__(self, device: XS1Actuator, sensor: XS1Sensor) -> None: """Initialize the actuator.""" super().__init__(device) self.sensor = sensor @property - def name(self): + def name(self) -> str: """Return the name of the device if any.""" return self.device.name() @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" if self.sensor is None: return None @@ -81,20 +82,10 @@ class XS1ThermostatEntity(XS1DeviceEntity, ClimateEntity): return self.device.unit() @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the current target temperature.""" return self.device.new_value() - @property - def min_temp(self): - """Return the minimum temperature.""" - return MIN_TEMP - - @property - def max_temp(self): - """Return the maximum temperature.""" - return MAX_TEMP - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) diff --git a/homeassistant/components/xs1/entity.py b/homeassistant/components/xs1/entity.py index c1ec43ec33c..61601066636 100644 --- a/homeassistant/components/xs1/entity.py +++ b/homeassistant/components/xs1/entity.py @@ -2,6 +2,8 @@ import asyncio +from xs1_api_client.device import XS1Device + from homeassistant.helpers.entity import Entity # Lock used to limit the amount of concurrent update requests @@ -13,7 +15,7 @@ UPDATE_LOCK = asyncio.Lock() class XS1DeviceEntity(Entity): """Representation of a base XS1 device.""" - def __init__(self, device): + def __init__(self, device: XS1Device) -> None: """Initialize the XS1 device.""" self.device = device diff --git a/homeassistant/components/xs1/sensor.py b/homeassistant/components/xs1/sensor.py index b3895d67d82..d1411fe540b 100644 --- a/homeassistant/components/xs1/sensor.py +++ b/homeassistant/components/xs1/sensor.py @@ -3,13 +3,15 @@ from __future__ import annotations from xs1_api_client.api_constants import ActuatorType +from xs1_api_client.device.actuator import XS1Actuator +from xs1_api_client.device.sensor import XS1Sensor from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS +from . import ACTUATORS, DOMAIN, SENSORS from .entity import XS1DeviceEntity @@ -20,8 +22,8 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the XS1 sensor platform.""" - sensors = hass.data[COMPONENT_DOMAIN][SENSORS] - actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS] + sensors: list[XS1Sensor] = hass.data[DOMAIN][SENSORS] + actuators: list[XS1Actuator] = hass.data[DOMAIN][ACTUATORS] sensor_entities = [] for sensor in sensors: @@ -35,16 +37,16 @@ def setup_platform( break if not belongs_to_climate_actuator: - sensor_entities.append(XS1Sensor(sensor)) + sensor_entities.append(XS1SensorEntity(sensor)) add_entities(sensor_entities) -class XS1Sensor(XS1DeviceEntity, SensorEntity): +class XS1SensorEntity(XS1DeviceEntity, SensorEntity): """Representation of a Sensor.""" @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self.device.name() @@ -54,6 +56,6 @@ class XS1Sensor(XS1DeviceEntity, SensorEntity): return self.device.value() @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return self.device.unit() diff --git a/homeassistant/components/xs1/switch.py b/homeassistant/components/xs1/switch.py index a8f66390a6d..232bd590c61 100644 --- a/homeassistant/components/xs1/switch.py +++ b/homeassistant/components/xs1/switch.py @@ -5,13 +5,14 @@ from __future__ import annotations from typing import Any from xs1_api_client.api_constants import ActuatorType +from xs1_api_client.device.actuator import XS1Actuator from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN +from . import ACTUATORS, DOMAIN from .entity import XS1DeviceEntity @@ -22,7 +23,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the XS1 switch platform.""" - actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS] + actuators: list[XS1Actuator] = hass.data[DOMAIN][ACTUATORS] add_entities( XS1SwitchEntity(actuator) @@ -36,12 +37,12 @@ class XS1SwitchEntity(XS1DeviceEntity, SwitchEntity): """Representation of a XS1 switch actuator.""" @property - def name(self): + def name(self) -> str: """Return the name of the device if any.""" return self.device.name() @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self.device.value() == 100 diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index f87d29fffed..e6ecc0ee0b8 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from aioymaps import CaptchaError, NoSessionError, YandexMapsRequester import voluptuous as vol @@ -71,6 +72,7 @@ class DiscoverYandexTransport(SensorEntity): """Implementation of yandex_transport sensor.""" _attr_attribution = "Data provided by maps.yandex.ru" + _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_icon = "mdi:bus" def __init__(self, requester: YandexMapsRequester, stop_id, routes, name) -> None: @@ -78,13 +80,15 @@ class DiscoverYandexTransport(SensorEntity): self.requester = requester self._stop_id = stop_id self._routes = routes - self._state = None - self._name = name - self._attrs = None + self._attr_name = name - async def async_update(self, *, tries=0): + async def async_update(self) -> None: """Get the latest data from maps.yandex.ru and update the states.""" - attrs = {} + await self._try_update(tries=0) + + async def _try_update(self, *, tries: int) -> None: + """Get the latest data from maps.yandex.ru and update the states.""" + attrs: dict[str, Any] = {} closer_time = None try: yandex_reply = await self.requester.get_stop_info(self._stop_id) @@ -108,7 +112,7 @@ class DiscoverYandexTransport(SensorEntity): if tries > 0: return await self.requester.set_new_session() - await self.async_update(tries=tries + 1) + await self._try_update(tries=tries + 1) return stop_name = data["name"] @@ -146,27 +150,9 @@ class DiscoverYandexTransport(SensorEntity): attrs[STOP_NAME] = stop_name if closer_time is None: - self._state = None + self._attr_native_value = None else: - self._state = dt_util.utc_from_timestamp(closer_time).replace(microsecond=0) - self._attrs = attrs - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the device class.""" - return SensorDeviceClass.TIMESTAMP - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attrs + self._attr_native_value = dt_util.utc_from_timestamp(closer_time).replace( + microsecond=0 + ) + self._attr_extra_state_attributes = attrs diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index b2fac03954d..10b84f933ef 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -66,36 +66,22 @@ async def async_setup_platform( class YiCamera(Camera): """Define an implementation of a Yi Camera.""" - def __init__(self, hass, config): + _attr_brand = DEFAULT_BRAND + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Initialize.""" super().__init__() self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS) - self._last_image = None + self._last_image: bytes | None = None self._last_url = None self._manager = get_ffmpeg_manager(hass) - self._name = config[CONF_NAME] - self._is_on = True + self._attr_name = config[CONF_NAME] self.host = config[CONF_HOST] self.port = config[CONF_PORT] self.path = config[CONF_PATH] self.user = config[CONF_USERNAME] self.passwd = config[CONF_PASSWORD] - @property - def brand(self): - """Camera brand.""" - return DEFAULT_BRAND - - @property - def is_on(self): - """Determine whether the camera is on.""" - return self._is_on - - @property - def name(self): - """Return the name of this camera.""" - return self._name - async def _get_latest_video_url(self): """Retrieve the latest video file from the customized Yi FTP server.""" ftp = Client() @@ -122,14 +108,14 @@ class YiCamera(Camera): return None await ftp.quit() - self._is_on = True + self._attr_is_on = True return ( f"ftp://{self.user}:{self.passwd}@{self.host}:" f"{self.port}{self.path}/{latest_dir}/{videos[-1]}" ) except (ConnectionRefusedError, StatusCodeError) as err: _LOGGER.error("Error while fetching video: %s", err) - self._is_on = False + self._attr_is_on = False return None async def async_camera_image( @@ -151,7 +137,7 @@ class YiCamera(Camera): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - if not self._is_on: + if not self._attr_is_on: return None stream = CameraMjpeg(self._manager.binary) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7ba7433f53f..3dd5aa7c974 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.typing import ConfigType from . import api -from .const import DOMAIN, YOLINK_EVENT +from .const import ATTR_LORA_INFO, DOMAIN, YOLINK_EVENT from .coordinator import YoLinkCoordinator from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS from .services import async_register_services @@ -72,6 +72,8 @@ class YoLinkHomeMessageListener(MessageListener): if device_coordinator is None: return device_coordinator.dev_online = True + if (loraInfo := msg_data.get(ATTR_LORA_INFO)) is not None: + device_coordinator.dev_net_type = loraInfo.get("devNetType") device_coordinator.async_set_updated_data(msg_data) # handling events if ( diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 30c04d3a424..7f965650354 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -11,6 +11,7 @@ from yolink.const import ( ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ) @@ -25,7 +26,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .const import ( + DEV_MODEL_WATER_METER_YS5018_EC, + DEV_MODEL_WATER_METER_YS5018_UC, + DOMAIN, +) from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -37,6 +42,7 @@ class YoLinkBinarySensorEntityDescription(BinarySensorEntityDescription): exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True state_key: str = "state" value: Callable[[Any], bool | None] = lambda _: None + should_update_entity: Callable = lambda state: True SENSOR_DEVICE_TYPE = [ @@ -46,6 +52,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ] @@ -91,8 +98,25 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( state_key="alarm", device_class=BinarySensorDeviceClass.MOISTURE, value=lambda state: state.get("leak") if state is not None else None, + # This property will be lost during valve operation. + should_update_entity=lambda value: value is not None, + exists_fn=lambda device: ( + device.device_type + in [ + ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ] + ), + ), + YoLinkBinarySensorEntityDescription( + key="water_running", + translation_key="water_running", + value=lambda state: state.get("waterFlowing") if state is not None else None, + should_update_entity=lambda value: value is not None, exists_fn=lambda device: ( device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER + and device.device_model_name + in [DEV_MODEL_WATER_METER_YS5018_EC, DEV_MODEL_WATER_METER_YS5018_UC] ), ), ) @@ -141,9 +165,13 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): @callback def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" - self._attr_is_on = self.entity_description.value( - state.get(self.entity_description.state_key) - ) + if ( + _attr_val := self.entity_description.value( + state.get(self.entity_description.state_key) + ) + ) is None or self.entity_description.should_update_entity(_attr_val) is False: + return + self._attr_is_on = _attr_val self.async_write_ha_state() @property diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 8879ef15125..9556c1bbd82 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -12,6 +12,7 @@ ATTR_VOLUME = "volume" ATTR_TEXT_MESSAGE = "message" ATTR_REPEAT = "repeat" ATTR_TONE = "tone" +ATTR_LORA_INFO = "loraInfo" YOLINK_EVENT = f"{DOMAIN}_event" YOLINK_OFFLINE_TIME = 32400 @@ -37,3 +38,7 @@ DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC" DEV_MODEL_SWITCH_YS5708_EC = "YS5708-EC" DEV_MODEL_SWITCH_YS5709_UC = "YS5709-UC" DEV_MODEL_SWITCH_YS5709_EC = "YS5709-EC" +DEV_MODEL_LEAK_STOP_YS5009 = "YS5009" +DEV_MODEL_LEAK_STOP_YS5029 = "YS5029" +DEV_MODEL_WATER_METER_YS5018_EC = "YS5018-EC" +DEV_MODEL_WATER_METER_YS5018_UC = "YS5018-UC" diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index d18a37bd276..7d5323663de 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_DEVICE_STATE, DOMAIN, YOLINK_OFFLINE_TIME +from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIME _LOGGER = logging.getLogger(__name__) @@ -47,6 +47,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): self.device = device self.paired_device = paired_device self.dev_online = True + self.dev_net_type = None async def _async_update_data(self) -> dict: """Fetch device state.""" @@ -76,7 +77,15 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): except YoLinkAuthFailError as yl_auth_err: raise ConfigEntryAuthFailed from yl_auth_err except YoLinkClientError as yl_client_err: + _LOGGER.error( + "Failed to obtain device status, device: %s, error: %s ", + self.device.device_id, + yl_client_err, + ) raise UpdateFailed from yl_client_err if device_state is not None: + dev_lora_info = device_state.get(ATTR_LORA_INFO) + if dev_lora_info is not None: + self.dev_net_type = dev_lora_info.get("devNetType") return device_state return {} diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 0f500b72404..7828bf91541 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -45,7 +45,7 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def _handle_coordinator_update(self) -> None: """Update state.""" data = self.coordinator.data - if data is not None: + if data is not None and len(data) > 0: self.update_entity_state(data) @property diff --git a/homeassistant/components/yolink/icons.json b/homeassistant/components/yolink/icons.json index c58d219a2e0..6d9062a92b8 100644 --- a/homeassistant/components/yolink/icons.json +++ b/homeassistant/components/yolink/icons.json @@ -1,5 +1,10 @@ { "entity": { + "binary_sensor": { + "water_running": { + "default": "mdi:waves-arrow-right" + } + }, "number": { "config_volume": { "default": "mdi:volume-high" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 511b7718e26..bc32d0eea83 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -12,9 +12,11 @@ from yolink.const import ( ATTR_DEVICE_FINGER, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_LOCK, + ATTR_DEVICE_LOCK_V2, ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_OUTLET, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_OUTLET, ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, @@ -95,7 +97,9 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_LOCK, + ATTR_DEVICE_LOCK_V2, ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_GARAGE_DOOR_CONTROLLER, @@ -112,10 +116,12 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, + ATTR_DEVICE_LOCK_V2, ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ] MCU_DEV_TEMPERATURE_SENSOR = [ @@ -211,14 +217,14 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( translation_key="power_failure_alarm", device_class=SensorDeviceClass.ENUM, options=["normal", "alert", "off"], - exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_POWER_FAILURE_ALARM, ), YoLinkSensorEntityDescription( key="mute", translation_key="power_failure_alarm_mute", device_class=SensorDeviceClass.ENUM, options=["muted", "unmuted"], - exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_POWER_FAILURE_ALARM, value=lambda value: "muted" if value is True else "unmuted", ), YoLinkSensorEntityDescription( @@ -226,7 +232,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( translation_key="power_failure_alarm_volume", device_class=SensorDeviceClass.ENUM, options=["low", "medium", "high"], - exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_POWER_FAILURE_ALARM, value=cvt_volume, ), YoLinkSensorEntityDescription( @@ -234,14 +240,14 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( translation_key="power_failure_alarm_beep", device_class=SensorDeviceClass.ENUM, options=["enabled", "disabled"], - exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_POWER_FAILURE_ALARM, value=lambda value: "enabled" if value is True else "disabled", ), YoLinkSensorEntityDescription( key="waterDepth", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.METERS, - exists_fn=lambda device: device.device_type in ATTR_DEVICE_WATER_DEPTH_SENSOR, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_WATER_DEPTH_SENSOR, ), YoLinkSensorEntityDescription( key="meter_reading", @@ -251,7 +257,29 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, should_update_entity=lambda value: value is not None, exists_fn=lambda device: ( - device.device_type in ATTR_DEVICE_WATER_METER_CONTROLLER + device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER + ), + ), + YoLinkSensorEntityDescription( + key="meter_1_reading", + translation_key="water_meter_1_reading", + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + should_update_entity=lambda value: value is not None, + exists_fn=lambda device: ( + device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER + ), + ), + YoLinkSensorEntityDescription( + key="meter_2_reading", + translation_key="water_meter_2_reading", + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + should_update_entity=lambda value: value is not None, + exists_fn=lambda device: ( + device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER ), ), YoLinkSensorEntityDescription( diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 8867457342f..d38ea248c31 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -44,6 +44,9 @@ } }, "entity": { + "binary_sensor": { + "water_running": { "name": "Water is flowing" } + }, "switch": { "usb_ports": { "name": "USB ports" }, "plug_1": { "name": "Plug 1" }, @@ -87,6 +90,12 @@ }, "water_meter_reading": { "name": "Water meter reading" + }, + "water_meter_1_reading": { + "name": "Water meter 1 reading" + }, + "water_meter_2_reading": { + "name": "Water meter 2 reading" } }, "number": { @@ -97,6 +106,12 @@ "valve": { "meter_valve_state": { "name": "Valve state" + }, + "meter_valve_1_state": { + "name": "Valve 1" + }, + "meter_valve_2_state": { + "name": "Valve 2" } } }, diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index 26ce72a53d1..0e8a5e61855 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -6,7 +6,11 @@ from collections.abc import Callable from dataclasses import dataclass from yolink.client_request import ClientRequest -from yolink.const import ATTR_DEVICE_WATER_METER_CONTROLLER +from yolink.const import ( + ATTR_DEVICE_MODEL_A, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_WATER_METER_CONTROLLER, +) from yolink.device import YoLinkDevice from homeassistant.components.valve import ( @@ -30,6 +34,7 @@ class YoLinkValveEntityDescription(ValveEntityDescription): exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True value: Callable = lambda state: state + channel_index: int | None = None DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( @@ -42,9 +47,32 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( == ATTR_DEVICE_WATER_METER_CONTROLLER and not device.device_model_name.startswith(DEV_MODEL_WATER_METER_YS5007), ), + YoLinkValveEntityDescription( + key="valve_1_state", + translation_key="meter_valve_1_state", + device_class=ValveDeviceClass.WATER, + value=lambda value: value != "open" if value is not None else None, + exists_fn=lambda device: ( + device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER + ), + channel_index=0, + ), + YoLinkValveEntityDescription( + key="valve_2_state", + translation_key="meter_valve_2_state", + device_class=ValveDeviceClass.WATER, + value=lambda value: value != "open" if value is not None else None, + exists_fn=lambda device: ( + device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER + ), + channel_index=1, + ), ) -DEVICE_TYPE = [ATTR_DEVICE_WATER_METER_CONTROLLER] +DEVICE_TYPE = [ + ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, +] async def async_setup_entry( @@ -102,7 +130,17 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): async def _async_invoke_device(self, state: str) -> None: """Call setState api to change valve state.""" - await self.call_device(ClientRequest("setState", {"valve": state})) + if ( + self.coordinator.device.device_type + == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER + ): + channel_index = self.entity_description.channel_index + if channel_index is not None: + await self.call_device( + ClientRequest("setState", {"valves": {str(channel_index): state}}) + ) + else: + await self.call_device(ClientRequest("setState", {"valve": state})) self._attr_is_closed = state == "close" self.async_write_ha_state() @@ -113,3 +151,11 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): async def async_close_valve(self) -> None: """Close valve.""" await self._async_invoke_device("close") + + @property + def available(self) -> bool: + """Return true is device is available.""" + if self.coordinator.dev_net_type is not None: + # When the device operates in Class A mode, it cannot be controlled. + return self.coordinator.dev_net_type != ATTR_DEVICE_MODEL_A + return super().available diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 128c23f7082..224ace3d405 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -6,7 +6,11 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant @@ -54,6 +58,7 @@ SENSOR_TYPES = [ key="subscribers", translation_key="subscribers", native_unit_of_measurement="subscribers", + state_class=SensorStateClass.MEASUREMENT, available_fn=lambda _: True, value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT], entity_picture_fn=lambda channel: channel[ATTR_ICON], @@ -63,6 +68,7 @@ SENSOR_TYPES = [ key="views", translation_key="views", native_unit_of_measurement="views", + state_class=SensorStateClass.TOTAL_INCREASING, available_fn=lambda _: True, value_fn=lambda channel: channel[ATTR_TOTAL_VIEWS], entity_picture_fn=lambda channel: channel[ATTR_ICON], diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index ec8850b187d..6b3b38bdde8 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -107,13 +107,13 @@ class ZestimateDataSensor(SensorEntity): attributes["address"] = self.address return attributes - def update(self): + def update(self) -> None: """Get the latest data and update the states.""" try: response = requests.get(_RESOURCE, params=self.params, timeout=5) data = response.content.decode("utf-8") - data_dict = xmltodict.parse(data).get(ZESTIMATE) + data_dict = xmltodict.parse(data)[ZESTIMATE] error_code = int(data_dict["message"]["code"]) if error_code != 0: _LOGGER.error("The API returned: %s", data_dict["message"]["text"]) diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 8e8509e62a5..75d22ce28a1 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN as ZHA_DOMAIN +from .const import DOMAIN from .helpers import async_get_zha_device_proxy, get_zha_data CONF_SUBTYPE = "subtype" @@ -104,7 +104,7 @@ async def async_get_triggers( return [ { CONF_DEVICE_ID: device_id, - CONF_DOMAIN: ZHA_DOMAIN, + CONF_DOMAIN: DOMAIN, CONF_PLATFORM: DEVICE, CONF_TYPE: trigger, CONF_SUBTYPE: subtype, diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json index d43e213aa4a..5caa1dec373 100644 --- a/homeassistant/components/zha/icons.json +++ b/homeassistant/components/zha/icons.json @@ -5,6 +5,29 @@ "default": "mdi:hand-wave" } }, + "fan": { + "fan": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "mdi:fan-auto", + "smart": "mdi:fan-auto" + } + } + } + } + }, + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "colorloop": "mdi:looks" + } + } + } + } + }, "number": { "timer_duration": { "default": "mdi:timer" diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index 05539a063d2..38fe9f92e64 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -12,7 +12,7 @@ from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from .const import DOMAIN as ZHA_DOMAIN +from .const import DOMAIN from .helpers import async_get_zha_device_proxy if TYPE_CHECKING: @@ -84,4 +84,4 @@ def async_describe_events( LOGBOOK_ENTRY_MESSAGE: message, } - async_describe_event(ZHA_DOMAIN, ZHA_EVENT, async_describe_zha_event) + async_describe_event(DOMAIN, ZHA_EVENT, async_describe_zha_event) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index ae337c2a5f5..4a5ec7be1dc 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.57"], + "requirements": ["zha==0.0.59"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 567e2a5b37a..7a6e40af7e7 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -11,7 +11,6 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import UndefinedType from .entity import ZHAEntity from .helpers import ( @@ -46,17 +45,6 @@ async def async_setup_entry( class ZhaNumber(ZHAEntity, RestoreNumber): """Representation of a ZHA Number entity.""" - @property - def name(self) -> str | UndefinedType | None: - """Return the name of the number entity.""" - if (description := self.entity_data.entity.description) is None: - return super().name - - # The name of this entity is reported by the device itself. - # For backwards compatibility, we keep the same format as before. This - # should probably be changed in the future to omit the prefix. - return f"{super().name} {description}" - @property def native_value(self) -> float | None: """Return the current value.""" diff --git a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py index 566158eff56..5b1eed18014 100644 --- a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py +++ b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py @@ -37,16 +37,6 @@ class HardwareType(enum.StrEnum): OTHER = "other" -DISABLE_MULTIPAN_URL = { - HardwareType.YELLOW: ( - "https://yellow.home-assistant.io/guides/disable-multiprotocol/#flash-the-silicon-labs-radio-firmware" - ), - HardwareType.SKYCONNECT: ( - "https://skyconnect.home-assistant.io/procedures/disable-multiprotocol/#step-flash-the-silicon-labs-radio-firmware" - ), - HardwareType.OTHER: None, -} - ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED = "wrong_silabs_firmware_installed" @@ -99,7 +89,6 @@ async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> boo issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, is_fixable=False, is_persistent=True, - learn_more_url=DISABLE_MULTIPAN_URL[hardware_type], severity=ir.IssueSeverity.ERROR, translation_key=( ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index d6a812569f5..95bf339f7d9 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -659,7 +659,15 @@ }, "fan": { "fan": { - "name": "[%key:component::fan::title%]" + "name": "[%key:component::fan::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "smart": "Smart" + } + } + } }, "fan_group": { "name": "Fan group" @@ -667,7 +675,14 @@ }, "light": { "light": { - "name": "[%key:component::light::title%]" + "name": "[%key:component::light::title%]", + "state_attributes": { + "effect": { + "state": { + "colorloop": "Color loop" + } + } + } }, "light_group": { "name": "Light group" @@ -905,7 +920,7 @@ "name": "Fade time" }, "regulator_set_point": { - "name": "Regulator set point" + "name": "Regulator setpoint" }, "detection_delay": { "name": "Detection delay" @@ -1137,6 +1152,9 @@ }, "external_temperature_sensor_value": { "name": "External temperature sensor value" + }, + "update_frequency": { + "name": "Update frequency" } }, "select": { @@ -1207,7 +1225,7 @@ "name": "Decoupled mode" }, "detection_sensitivity": { - "name": "Detection Sensitivity" + "name": "Detection sensitivity" }, "keypad_lockout": { "name": "Keypad lockout" @@ -1367,6 +1385,9 @@ }, "alarm_sound_mode": { "name": "Alarm sound mode" + }, + "external_switch_type": { + "name": "External switch type" } }, "sensor": { @@ -1638,7 +1659,7 @@ "name": "Total power factor" }, "self_test_result": { - "name": "Self test result" + "name": "Self-test result" }, "lower_explosive_limit": { "name": "% Lower explosive limit" diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index af3287d3068..217636edbd5 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -216,12 +216,12 @@ class ZhongHongClimate(ClimateEntity): return self._device.fan_list @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self._device.min_temp @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self._device.max_temp diff --git a/homeassistant/components/zimi/__init__.py b/homeassistant/components/zimi/__init__.py new file mode 100644 index 00000000000..a00dd60ee5f --- /dev/null +++ b/homeassistant/components/zimi/__init__.py @@ -0,0 +1,73 @@ +"""The zcc integration.""" + +from __future__ import annotations + +import logging + +from zcc import ControlPoint, ControlPointError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import DOMAIN +from .helpers import async_connect_to_controller + +PLATFORMS = [ + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] + +_LOGGER = logging.getLogger(__name__) + + +type ZimiConfigEntry = ConfigEntry[ControlPoint] + + +async def async_setup_entry(hass: HomeAssistant, entry: ZimiConfigEntry) -> bool: + """Connect to Zimi Controller and register device.""" + + try: + api = await async_connect_to_controller( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ) + + except ControlPointError as error: + raise ConfigEntryNotReady(f"Zimi setup failed: {error}") from error + + _LOGGER.debug("\n%s", api.describe()) + + entry.runtime_data = api + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, api.mac)}, + manufacturer=api.brand, + name=f"{api.network_name}", + model="Zimi Cloud Connect", + sw_version=api.firmware_version, + connections={(CONNECTION_NETWORK_MAC, api.mac)}, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + _LOGGER.debug("Zimi setup complete") + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ZimiConfigEntry) -> bool: + """Unload a config entry.""" + + api = entry.runtime_data + api.disconnect() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/zimi/config_flow.py b/homeassistant/components/zimi/config_flow.py new file mode 100644 index 00000000000..1037a05a2ce --- /dev/null +++ b/homeassistant/components/zimi/config_flow.py @@ -0,0 +1,172 @@ +"""Config flow for zcc integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from zcc import ( + ControlPoint, + ControlPointCannotConnectError, + ControlPointConnectionRefusedError, + ControlPointDescription, + ControlPointDiscoveryService, + ControlPointError, + ControlPointInvalidHostError, + ControlPointTimeoutError, +) + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +DEFAULT_PORT = 5003 +STEP_MANUAL_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + +SELECTED_HOST_AND_PORT = "selected_host_and_port" + + +class ZimiConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for zcc.""" + + api: ControlPoint = None + api_descriptions: list[ControlPointDescription] + data: dict[str, Any] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial auto-discovery step.""" + + self.data = {} + + try: + self.api_descriptions = await ControlPointDiscoveryService().discovers() + except ControlPointError: + # ControlPointError is expected if no zcc are found on LAN + return await self.async_step_manual() + + if len(self.api_descriptions) == 1: + self.data[CONF_HOST] = self.api_descriptions[0].host + self.data[CONF_PORT] = self.api_descriptions[0].port + await self.check_connection(self.data[CONF_HOST], self.data[CONF_PORT]) + return await self.create_entry() + + return await self.async_step_selection() + + async def async_step_selection( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selection of zcc to configure if multiple are discovered.""" + + errors: dict[str, str] | None = {} + + if user_input is not None: + self.data[CONF_HOST] = user_input[SELECTED_HOST_AND_PORT].split(":")[0] + self.data[CONF_PORT] = int(user_input[SELECTED_HOST_AND_PORT].split(":")[1]) + errors = await self.check_connection( + self.data[CONF_HOST], self.data[CONF_PORT] + ) + if not errors: + return await self.create_entry() + + available_options = [ + SelectOptionDict( + label=f"{description.host}:{description.port}", + value=f"{description.host}:{description.port}", + ) + for description in self.api_descriptions + ] + + available_schema = vol.Schema( + { + vol.Required( + SELECTED_HOST_AND_PORT, default=available_options[0]["value"] + ): SelectSelector( + SelectSelectorConfig( + options=available_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ) + + return self.async_show_form( + step_id="selection", data_schema=available_schema, errors=errors + ) + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle manual configuration step if needed.""" + + errors: dict[str, str] | None = {} + + if user_input is not None: + self.data = {**self.data, **user_input} + + errors = await self.check_connection( + self.data[CONF_HOST], self.data[CONF_PORT] + ) + + if not errors: + return await self.create_entry() + + return self.async_show_form( + step_id="manual", + data_schema=self.add_suggested_values_to_schema( + STEP_MANUAL_DATA_SCHEMA, self.data + ), + errors=errors, + ) + + async def check_connection(self, host: str, port: int) -> dict[str, str] | None: + """Check connection to zcc. + + Stores mac and returns None if successful, otherwise returns error message. + """ + + try: + result = await ControlPointDiscoveryService().validate_connection( + self.data[CONF_HOST], self.data[CONF_PORT] + ) + except ControlPointInvalidHostError: + return {"base": "invalid_host"} + except ControlPointConnectionRefusedError: + return {"base": "connection_refused"} + except ControlPointCannotConnectError: + return {"base": "cannot_connect"} + except ControlPointTimeoutError: + return {"base": "timeout"} + except Exception: + _LOGGER.exception("Unexpected error") + return {"base": "unknown"} + + self.data[CONF_MAC] = format_mac(result.mac) + + return None + + async def create_entry(self) -> ConfigFlowResult: + """Create entry for zcc.""" + + await self.async_set_unique_id(self.data[CONF_MAC]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"ZIMI Controller ({self.data[CONF_HOST]}:{self.data[CONF_PORT]})", + data=self.data, + ) diff --git a/homeassistant/components/zimi/const.py b/homeassistant/components/zimi/const.py new file mode 100644 index 00000000000..1a426875b75 --- /dev/null +++ b/homeassistant/components/zimi/const.py @@ -0,0 +1,3 @@ +"""Constants for the zcc integration.""" + +DOMAIN = "zimi" diff --git a/homeassistant/components/zimi/cover.py b/homeassistant/components/zimi/cover.py new file mode 100644 index 00000000000..8f05e35e263 --- /dev/null +++ b/homeassistant/components/zimi/cover.py @@ -0,0 +1,93 @@ +"""Platform for cover integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ZimiConfigEntry +from .entity import ZimiEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Cover platform.""" + + api = config_entry.runtime_data + + doors = [ZimiCover(device, api) for device in api.doors] + + async_add_entities(doors) + + +class ZimiCover(ZimiEntity, CoverEntity): + """Representation of a Zimi cover.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover/door.""" + _LOGGER.debug("Sending close_cover() for %s", self.name) + await self._device.close_door() + + @property + def current_cover_position(self) -> int | None: + """Return the current cover/door position.""" + return self._device.percentage + + @property + def is_closed(self) -> bool | None: + """Return true if cover is closed.""" + return self._device.is_closed + + @property + def is_closing(self) -> bool | None: + """Return true if cover is closing.""" + return self._device.is_closing + + @property + def is_opening(self) -> bool | None: + """Return true if cover is opening.""" + return self._device.is_opening + + @property + def is_open(self) -> bool | None: + """Return true if cover is open.""" + return self._device.is_open + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover/door.""" + _LOGGER.debug("Sending open_cover() for %s", self.name) + await self._device.open_door() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Open the cover/door to a specified percentage.""" + if position := kwargs.get("position"): + _LOGGER.debug("Sending set_cover_position(%d) for %s", position, self.name) + await self._device.open_to_percentage(position) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + _LOGGER.debug( + "Stopping open_cover() by setting to current position for %s", self.name + ) + await self.async_set_cover_position(position=self.current_cover_position) diff --git a/homeassistant/components/zimi/entity.py b/homeassistant/components/zimi/entity.py new file mode 100644 index 00000000000..12d8f336bf0 --- /dev/null +++ b/homeassistant/components/zimi/entity.py @@ -0,0 +1,69 @@ +"""Base entity for zimi integrations.""" + +from __future__ import annotations + +import logging + +from zcc import ControlPoint +from zcc.device import ControlPointDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ZimiEntity(Entity): + """Representation of a Zimi API entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, device: ControlPointDevice, api: ControlPoint, use_device_name=True + ) -> None: + """Initialize an HA Entity which is a ZimiDevice.""" + + self._device = device + self._attr_unique_id = device.identifier + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.manufacture_info.identifier)}, + manufacturer=device.manufacture_info.manufacturer, + model=device.manufacture_info.model, + name=device.manufacture_info.name, + hw_version=device.manufacture_info.hwVersion, + sw_version=device.manufacture_info.firmwareVersion, + suggested_area=device.room, + via_device=(DOMAIN, api.mac), + ) + if use_device_name: + self._attr_name = device.name.strip() + self._attr_suggested_area = device.room + + @property + def available(self) -> bool: + """Return True if Home Assistant is able to read the state and control the underlying device.""" + return self._device.is_connected + + async def async_added_to_hass(self) -> None: + """Subscribe to the events.""" + await super().async_added_to_hass() + self._device.subscribe(self) + + async def async_will_remove_from_hass(self) -> None: + """Cleanup ZimiLight with removal of notification prior to removal.""" + self._device.unsubscribe(self) + await super().async_will_remove_from_hass() + + def notify(self, _observable: object) -> None: + """Receive notification from device that state has changed. + + No data is fetched for the notification but schedule_update_ha_state is called. + """ + + _LOGGER.debug( + "Received notification() for %s in %s", self._device.name, self._device.room + ) + self.schedule_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/zimi/fan.py b/homeassistant/components/zimi/fan.py new file mode 100644 index 00000000000..19c51371d1a --- /dev/null +++ b/homeassistant/components/zimi/fan.py @@ -0,0 +1,94 @@ +"""Platform for fan integration.""" + +from __future__ import annotations + +import logging +import math +from typing import Any + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +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 . import ZimiConfigEntry +from .entity import ZimiEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Fan platform.""" + + api = config_entry.runtime_data + + async_add_entities([ZimiFan(device, api) for device in api.fans]) + + +class ZimiFan(ZimiEntity, FanEntity): + """Representation of a Zimi fan.""" + + _attr_speed_range = (0, 7) + + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the desired speed for the fan.""" + + if percentage == 0: + await self.async_turn_off() + return + + target_speed = math.ceil( + percentage_to_ranged_value(self._attr_speed_range, percentage) + ) + + _LOGGER.debug( + "Sending async_set_percentage() for %s with percentage %s", + self.name, + percentage, + ) + + await self._device.set_fanspeed(target_speed) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Instruct the fan to turn on.""" + + _LOGGER.debug("Sending turn_on() for %s", self.name) + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the fan to turn off.""" + + _LOGGER.debug("Sending turn_off() for %s", self.name) + + await self._device.turn_off() + + @property + def percentage(self) -> int: + """Return the current speed percentage for the fan.""" + if not self._device.fanspeed: + return 0 + return ranged_value_to_percentage(self._attr_speed_range, self._device.fanspeed) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(self._attr_speed_range) diff --git a/homeassistant/components/zimi/helpers.py b/homeassistant/components/zimi/helpers.py new file mode 100644 index 00000000000..81d9a986f46 --- /dev/null +++ b/homeassistant/components/zimi/helpers.py @@ -0,0 +1,38 @@ +"""The zcc integration helpers.""" + +from __future__ import annotations + +import logging + +from zcc import ControlPoint, ControlPointDescription + +from homeassistant.exceptions import ConfigEntryNotReady + +_LOGGER = logging.getLogger(__name__) + + +async def async_connect_to_controller( + host: str, port: int, fast: bool = False +) -> ControlPoint: + """Connect to Zimi Cloud Controller with defined parameters.""" + + _LOGGER.debug("Connecting to %s:%d", host, port) + + api = ControlPoint( + description=ControlPointDescription( + host=host, + port=port, + ) + ) + await api.connect(fast=fast) + + if api.ready: + _LOGGER.debug("Connected") + + if not fast: + api.start_watchdog() + _LOGGER.debug("Started watchdog") + + return api + + raise ConfigEntryNotReady("Connection failed: not ready") diff --git a/homeassistant/components/zimi/light.py b/homeassistant/components/zimi/light.py new file mode 100644 index 00000000000..a93bbb53b3d --- /dev/null +++ b/homeassistant/components/zimi/light.py @@ -0,0 +1,103 @@ +"""Light platform for zcc integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from zcc import ControlPoint +from zcc.device import ControlPointDevice + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ZimiConfigEntry +from .entity import ZimiEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Light platform.""" + + api = config_entry.runtime_data + + lights: list[ZimiLight | ZimiDimmer] = [ + ZimiLight(device, api) for device in api.lights if device.type != "dimmer" + ] + + lights.extend( + [ZimiDimmer(device, api) for device in api.lights if device.type == "dimmer"] + ) + + async_add_entities(lights) + + +class ZimiLight(ZimiEntity, LightEntity): + """Representation of a Zimi Light.""" + + def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None: + """Initialize a ZimiLight.""" + + super().__init__(device, api) + + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on (with optional brightness).""" + + _LOGGER.debug( + "Sending turn_on() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + + _LOGGER.debug( + "Sending turn_off() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_off() + + +class ZimiDimmer(ZimiLight): + """Zimi Light supporting dimming.""" + + def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None: + """Initialize a ZimiDimmer.""" + super().__init__(device, api) + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + if self._device.type != "dimmer": + raise ValueError("ZimiDimmer needs a dimmable light") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on (with optional brightness).""" + + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) * 100 / 255 + _LOGGER.debug( + "Sending turn_on(brightness=%d) for %s in %s", + brightness, + self._device.name, + self._device.room, + ) + + await self._device.set_brightness(brightness) + + @property + def brightness(self) -> int | None: + """Return the brightness of the light.""" + return round(self._device.brightness * 255 / 100) diff --git a/homeassistant/components/zimi/manifest.json b/homeassistant/components/zimi/manifest.json new file mode 100644 index 00000000000..3e019d2f053 --- /dev/null +++ b/homeassistant/components/zimi/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "zimi", + "name": "zimi", + "codeowners": ["@markhannon"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/zimi", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["zcc-helper==3.5.2"] +} diff --git a/homeassistant/components/zimi/quality_scale.yaml b/homeassistant/components/zimi/quality_scale.yaml new file mode 100644 index 00000000000..98e6c5b627c --- /dev/null +++ b/homeassistant/components/zimi/quality_scale.yaml @@ -0,0 +1,99 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + There are no service actions. + appropriate-polling: + status: done + comment: | + There is no polling of the entities. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: done + comment: | + https://mark_hannon@bitbucket.org/mark_hannon/zcc.git + docs-actions: + status: exempt + comment: | + There are no service 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 + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: done + action-exceptions: todo + reauthentication-flow: + status: exempt + comment: | + There is no user authentication needed. + parallel-updates: + status: todo + comment: | + Test of parallel updates will be done before setting. + test-coverage: todo + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options flow + + # Gold + entity-translations: todo + entity-device-class: + status: todo + comment: | + Will set device classes for subsequent entities - not relevant for light. + devices: done + entity-category: todo + entity-disabled-by-default: todo + discovery: + status: todo + comment: > + Discovery is supported for the case where the Zimi Cloud Controller(s) are + connected to a local LAN network. Discover is not supported if the Zimi + Cloud Controller(s) are not connected to the local LAN network. + stale-devices: todo + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: + status: todo + comment: | + New devices will be automatically added - but only when the zcc connection is re-established. + discovery-update-info: + status: todo + comment: > + Discovery is not supported. + repair-issues: todo + docs-use-cases: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-data-update: done + docs-known-limitations: done + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use web sessions. + strict-typing: + status: todo diff --git a/homeassistant/components/zimi/sensor.py b/homeassistant/components/zimi/sensor.py new file mode 100644 index 00000000000..2c681f8e69e --- /dev/null +++ b/homeassistant/components/zimi/sensor.py @@ -0,0 +1,103 @@ +"""Platform for sensor integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from zcc import ControlPoint +from zcc.device import ControlPointDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import ZimiConfigEntry +from .entity import ZimiEntity + + +@dataclass(frozen=True, kw_only=True) +class ZimiSensorEntityDescription(SensorEntityDescription): + """Class describing Zimi sensor entities.""" + + value_fn: Callable[[ControlPointDevice], StateType] + + +GARAGE_SENSOR_DESCRIPTIONS: tuple[ZimiSensorEntityDescription, ...] = ( + ZimiSensorEntityDescription( + key="door_temperature", + translation_key="door_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda device: device.door_temp, + ), + ZimiSensorEntityDescription( + key="garage_battery", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + value_fn=lambda device: device.battery_level, + ), + ZimiSensorEntityDescription( + key="garage_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda device: device.garage_temp, + ), + ZimiSensorEntityDescription( + key="garage_humidty", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + value_fn=lambda device: device.garage_humidity, + ), +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Sensor platform.""" + + api = config_entry.runtime_data + + async_add_entities( + ZimiSensor(device, description, api) + for device in api.sensors + for description in GARAGE_SENSOR_DESCRIPTIONS + ) + + +class ZimiSensor(ZimiEntity, SensorEntity): + """Representation of a Zimi sensor.""" + + entity_description: ZimiSensorEntityDescription + + def __init__( + self, + device: ControlPointDevice, + description: ZimiSensorEntityDescription, + api: ControlPoint, + ) -> None: + """Initialize an ZimiSensor with specified type.""" + + super().__init__(device, api, use_device_name=False) + + self.entity_description = description + self._attr_unique_id = device.identifier + "." + self.entity_description.key + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/zimi/strings.json b/homeassistant/components/zimi/strings.json new file mode 100644 index 00000000000..e1c7944b25a --- /dev/null +++ b/homeassistant/components/zimi/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "title": "Zimi - Discover device(s)", + "description": "Discover and auto-configure Zimi Cloud Connect device." + }, + "selection": { + "title": "Zimi - Select device", + "description": "Select Zimi Cloud Connect device to configure.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "selected_host_and_port": "Selected ZCC" + }, + "data_description": { + "host": "Mandatory - ZCC IP address.", + "port": "Mandatory - ZCC port number (default=5003).", + "selected_host_and_port": "Selected ZCC IP address and port number" + } + }, + "manual": { + "title": "Zimi - Configure device", + "description": "Enter details of your Zimi Cloud Connect device.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::zimi::config::step::selection::data_description::host%]", + "port": "[%key:component::zimi::config::step::selection::data_description::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "connection_refused": "Connection refused" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "door_temperature": { + "name": "Outside temperature" + } + } + } +} diff --git a/homeassistant/components/zimi/switch.py b/homeassistant/components/zimi/switch.py new file mode 100644 index 00000000000..a5292602a6e --- /dev/null +++ b/homeassistant/components/zimi/switch.py @@ -0,0 +1,56 @@ +"""Switch platform for zcc integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ZimiConfigEntry +from .entity import ZimiEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Switch platform.""" + + api = config_entry.runtime_data + + outlets = [ZimiSwitch(device, api) for device in api.outlets] + + async_add_entities(outlets) + + +class ZimiSwitch(ZimiEntity, SwitchEntity): + """Representation of an Zimi Switch.""" + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the switch to turn on.""" + + _LOGGER.debug( + "Sending turn_on() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the switch to turn off.""" + + _LOGGER.debug( + "Sending turn_off() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_off() diff --git a/homeassistant/components/zoneminder/binary_sensor.py b/homeassistant/components/zoneminder/binary_sensor.py index 926780fc6da..f26f2351b5a 100644 --- a/homeassistant/components/zoneminder/binary_sensor.py +++ b/homeassistant/components/zoneminder/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as ZONEMINDER_DOMAIN +from . import DOMAIN async def async_setup_platform( @@ -23,7 +23,7 @@ async def async_setup_platform( ) -> None: """Set up the ZoneMinder binary sensor platform.""" sensors = [] - for host_name, zm_client in hass.data[ZONEMINDER_DOMAIN].items(): + for host_name, zm_client in hass.data[DOMAIN].items(): sensors.append(ZMAvailabilitySensor(host_name, zm_client)) add_entities(sensors) diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index 21513b4bed4..851b7492e06 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -13,7 +13,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as ZONEMINDER_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,7 @@ def setup_platform( filter_urllib3_logging() cameras = [] zm_client: ZoneMinder - for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): + for zm_client in hass.data[DOMAIN].values(): if not (monitors := zm_client.get_monitors()): raise PlatformNotReady( "Camera could not fetch any monitors from ZoneMinder" diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 4f79f8876e5..5663da0b308 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as ZONEMINDER_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -77,7 +77,7 @@ def setup_platform( sensors: list[SensorEntity] = [] zm_client: ZoneMinder - for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): + for zm_client in hass.data[DOMAIN].values(): if not (monitors := zm_client.get_monitors()): raise PlatformNotReady( "Sensor could not fetch any monitors from ZoneMinder" diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 13da0927196..7ab6f786cfb 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as ZONEMINDER_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -45,7 +45,7 @@ def setup_platform( switches: list[ZMSwitchMonitors] = [] zm_client: ZoneMinder - for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): + for zm_client in hass.data[DOMAIN].values(): if not (monitors := zm_client.get_monitors()): raise PlatformNotReady( "Switch could not fetch any monitors from ZoneMinder" diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 6e76b2f89cf..e8f2bf6f2d4 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -94,6 +94,7 @@ from .const import ( CONF_DATA_COLLECTION_OPTED_IN, CONF_INSTALLER_MODE, CONF_INTEGRATION_CREATED_ADDON, + CONF_KEEP_OLD_DEVICES, CONF_LR_S2_ACCESS_CONTROL_KEY, CONF_LR_S2_AUTHENTICATED_KEY, CONF_NETWORK_KEY, @@ -405,9 +406,10 @@ class DriverEvents: # 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 and device not in provisioned_devices: - self.dev_reg.async_remove_device(device.id) + if not self.config_entry.data.get(CONF_KEEP_OLD_DEVICES): + for device in stored_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 if controller.own_node: @@ -1117,38 +1119,6 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.error(err) -async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry -) -> bool: - """Remove a config entry from a device.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] - - # Driver may not be ready yet so we can't allow users to remove a device since - # we need to check if the device is still known to the controller - if (driver := client.driver) is None: - LOGGER.error("Driver for %s is not ready", config_entry.title) - return False - - # If a node is found on the controller that matches the hardware based identifier - # on the device, prevent the device from being removed. - if next( - ( - node - for node in driver.controller.nodes.values() - if get_device_id_ext(driver, node) in device_entry.identifiers - ), - None, - ): - return False - - controller_events: ControllerEvents = config_entry.runtime_data[ - DATA_DRIVER_EVENTS - ].controller_events - controller_events.registered_unique_ids.pop(device_entry.id, None) - controller_events.discovered_value_ids.pop(device_entry.id, None) - return True - - async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Z-Wave JS add-on is installed and running.""" addon_manager = _get_addon_manager(hass) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index add16e7bdc8..c1a24b6ea65 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -676,10 +676,18 @@ async def websocket_node_alerts( connection.send_error(msg[ID], ERR_NOT_LOADED, str(err)) return + comments = node.device_config.metadata.comments + if node.in_interview: + comments.append( + { + "level": "warning", + "text": "This device is currently being interviewed and may not be fully operational.", + } + ) connection.send_result( msg[ID], { - "comments": node.device_config.metadata.comments, + "comments": comments, "is_embedded": node.device_config.is_embedded, }, ) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index e442fb59cfc..5e8e7022839 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import base64 from contextlib import suppress from datetime import datetime import logging @@ -45,8 +46,6 @@ from .addon import get_addon_manager from .const import ( ADDON_SLUG, CONF_ADDON_DEVICE, - CONF_ADDON_EMULATE_HARDWARE, - CONF_ADDON_LOG_LEVEL, CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, CONF_ADDON_LR_S2_AUTHENTICATED_KEY, CONF_ADDON_NETWORK_KEY, @@ -55,6 +54,7 @@ from .const import ( CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_INTEGRATION_CREATED_ADDON, + CONF_KEEP_OLD_DEVICES, CONF_LR_S2_ACCESS_CONTROL_KEY, CONF_LR_S2_AUTHENTICATED_KEY, CONF_S0_LEGACY_KEY, @@ -76,17 +76,7 @@ TITLE = "Z-Wave JS" ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 40 -CONF_EMULATE_HARDWARE = "emulate_hardware" -CONF_LOG_LEVEL = "log_level" -ADDON_LOG_LEVELS = { - "error": "Error", - "warn": "Warn", - "info": "Info", - "verbose": "Verbose", - "debug": "Debug", - "silly": "Silly", -} ADDON_USER_INPUT_MAP = { CONF_ADDON_DEVICE: CONF_USB_PATH, CONF_ADDON_S0_LEGACY_KEY: CONF_S0_LEGACY_KEY, @@ -95,8 +85,6 @@ ADDON_USER_INPUT_MAP = { CONF_ADDON_S2_UNAUTHENTICATED_KEY: CONF_S2_UNAUTHENTICATED_KEY, CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: CONF_LR_S2_ACCESS_CONTROL_KEY, CONF_ADDON_LR_S2_AUTHENTICATED_KEY: CONF_LR_S2_AUTHENTICATED_KEY, - CONF_ADDON_LOG_LEVEL: CONF_LOG_LEVEL, - CONF_ADDON_EMULATE_HARDWARE: CONF_EMULATE_HARDWARE, } ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) @@ -149,7 +137,14 @@ def get_usb_ports() -> dict[str, str]: pid, ) port_descriptions[dev_path] = human_name - return port_descriptions + + # Sort the dictionary by description, putting "n/a" last + return dict( + sorted( + port_descriptions.items(), + key=lambda x: x[1].lower().startswith("n/a"), + ) + ) async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: @@ -162,8 +157,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - _title: str - def __init__(self) -> None: """Set up flow instance.""" self.s0_legacy_key: str | None = None @@ -185,11 +178,12 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): 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.backup_filepath: Path | None = None self.use_addon = False self._migrating = False self._reconfigure_config_entry: ConfigEntry | None = None self._usb_discovery = False + self._recommended_install = False async def async_step_install_addon( self, user_input: dict[str, Any] | None = None @@ -365,10 +359,22 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" if is_hassio(self.hass): - return await self.async_step_on_supervisor() + return await self.async_step_installation_type() return await self.async_step_manual() + async def async_step_installation_type( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the installation type step.""" + return self.async_show_menu( + step_id="installation_type", + menu_options=[ + "intent_recommended", + "intent_custom", + ], + ) + async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -425,7 +431,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): # 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( + self._reconfigure_config_entry = next( ( entry for entry in current_config_entries @@ -433,7 +439,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ), None, ) - if not config_entry: + if not self._reconfigure_config_entry: return self.async_abort(reason="addon_required") vid = discovery_info.vid @@ -482,34 +488,12 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) 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( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle USB Discovery confirmation.""" - if user_input is None: - return self.async_show_form( - step_id="usb_confirm", - description_placeholders={CONF_NAME: self._title}, - ) 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") + if current_config_entries: return await self.async_step_intent_migrate() - return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) + return await self.async_step_installation_type() async def async_step_manual( self, user_input: dict[str, Any] | None = None @@ -586,6 +570,21 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="hassio_confirm") + async def async_step_intent_recommended( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Select recommended installation type.""" + self._recommended_install = True + return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) + + async def async_step_intent_custom( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Select custom installation type.""" + if self._usb_discovery: + return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) + return await self.async_step_on_supervisor() + async def async_step_on_supervisor( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -634,31 +633,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): addon_info = await self._async_get_addon_info() addon_config = addon_info.options - if user_input is not None: - self.s0_legacy_key = user_input[CONF_S0_LEGACY_KEY] - self.s2_access_control_key = user_input[CONF_S2_ACCESS_CONTROL_KEY] - self.s2_authenticated_key = user_input[CONF_S2_AUTHENTICATED_KEY] - self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY] - self.lr_s2_access_control_key = user_input[CONF_LR_S2_ACCESS_CONTROL_KEY] - self.lr_s2_authenticated_key = user_input[CONF_LR_S2_AUTHENTICATED_KEY] - if not self._usb_discovery: - self.usb_path = user_input[CONF_USB_PATH] - - 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, - CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, - CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, - CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, - } - - await self._async_set_addon_config(addon_config_updates) - - return await self.async_step_start_addon() - - usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or "" s0_legacy_key = addon_config.get( CONF_ADDON_S0_LEGACY_KEY, self.s0_legacy_key or "" ) @@ -678,22 +652,67 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" ) - schema: VolDictType = { - vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, - vol.Optional( - CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key - ): str, - vol.Optional(CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key): str, - vol.Optional( - CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key - ): str, - vol.Optional( - CONF_LR_S2_ACCESS_CONTROL_KEY, default=lr_s2_access_control_key - ): str, - vol.Optional( - CONF_LR_S2_AUTHENTICATED_KEY, default=lr_s2_authenticated_key - ): str, - } + if self._recommended_install and self._usb_discovery: + # Recommended installation with USB discovery, skip asking for keys + user_input = {} + + if user_input is not None: + self.s0_legacy_key = user_input.get(CONF_S0_LEGACY_KEY, s0_legacy_key) + self.s2_access_control_key = user_input.get( + CONF_S2_ACCESS_CONTROL_KEY, s2_access_control_key + ) + self.s2_authenticated_key = user_input.get( + CONF_S2_AUTHENTICATED_KEY, s2_authenticated_key + ) + self.s2_unauthenticated_key = user_input.get( + CONF_S2_UNAUTHENTICATED_KEY, s2_unauthenticated_key + ) + self.lr_s2_access_control_key = user_input.get( + CONF_LR_S2_ACCESS_CONTROL_KEY, lr_s2_access_control_key + ) + self.lr_s2_authenticated_key = user_input.get( + CONF_LR_S2_AUTHENTICATED_KEY, lr_s2_authenticated_key + ) + if not self._usb_discovery: + self.usb_path = user_input[CONF_USB_PATH] + + 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, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + } + + await self._async_set_addon_config(addon_config_updates) + + return await self.async_step_start_addon() + + usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or "" + schema: VolDictType = ( + {} + if self._recommended_install + else { + vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, + vol.Optional( + CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key + ): str, + vol.Optional( + CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key + ): str, + vol.Optional( + CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key + ): str, + vol.Optional( + CONF_LR_S2_ACCESS_CONTROL_KEY, default=lr_s2_access_control_key + ): str, + vol.Optional( + CONF_LR_S2_AUTHENTICATED_KEY, default=lr_s2_authenticated_key + ): str, + } + ) if not self._usb_discovery: try: @@ -1064,10 +1083,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, - CONF_ADDON_LOG_LEVEL: user_input[CONF_LOG_LEVEL], - CONF_ADDON_EMULATE_HARDWARE: user_input.get( - CONF_EMULATE_HARDWARE, False - ), } await self._async_set_addon_config(addon_config_updates) @@ -1102,8 +1117,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): lr_s2_authenticated_key = addon_config.get( CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" ) - log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") - emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) try: ports = await async_get_usb_ports(self.hass) @@ -1130,10 +1143,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): vol.Optional( CONF_LR_S2_AUTHENTICATED_KEY, default=lr_s2_authenticated_key ): str, - vol.Optional(CONF_LOG_LEVEL, default=log_level): vol.In( - ADDON_LOG_LEVELS - ), - vol.Optional(CONF_EMULATE_HARDWARE, default=emulate_hardware): bool, } ) @@ -1156,6 +1165,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Failed to get USB ports: %s", err) return self.async_abort(reason="usb_ports_failed") + addon_info = await self._async_get_addon_info() + addon_config = addon_info.options + old_usb_path = addon_config.get(CONF_ADDON_DEVICE, "") + # Remove the old controller from the ports list. + ports.pop( + await self.hass.async_add_executor_job(usb.get_serial_by_id, old_usb_path), + None, + ) + data_schema = vol.Schema( { vol.Required(CONF_USB_PATH): vol.In(ports), @@ -1177,11 +1195,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Restore failed.""" if user_input is not None: return await self.async_step_restore_nvm() + assert self.backup_filepath is not None + assert self.backup_data is not None return self.async_show_form( step_id="restore_failed", description_placeholders={ "file_path": str(self.backup_filepath), + "file_url": f"data:application/octet-stream;base64,{base64.b64encode(self.backup_data).decode('ascii')}", + "file_name": self.backup_filepath.name, }, ) @@ -1319,12 +1341,14 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): 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" + self.backup_filepath = Path( + 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_filepath.write_bytes, self.backup_data, ) except OSError as err: @@ -1336,9 +1360,20 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): config_entry = self._reconfigure_config_entry assert config_entry is not None + # Make sure we keep the old devices + # so that user customizations are not lost, + # when loading the config entry. + self.hass.config_entries.async_update_entry( + config_entry, data=config_entry.data | {CONF_KEEP_OLD_DEVICES: True} + ) + # Reload the config entry to reconnect the client after the addon restart await self.hass.config_entries.async_reload(config_entry.entry_id) + data = config_entry.data.copy() + data.pop(CONF_KEEP_OLD_DEVICES, None) + self.hass.config_entries.async_update_entry(config_entry, data=data) + @callback def forward_progress(event: dict) -> None: """Forward progress events to frontend.""" @@ -1389,6 +1424,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): config_entry, unique_id=str(version_info.home_id) ) await self.hass.config_entries.async_reload(config_entry.entry_id) + + # Reload the config entry two times to clean up + # the stale device entry. + # Since both the old and the new controller have the same node id, + # but different hardware identifiers, the integration + # will create a new device for the new controller, on the first reload, + # but not immediately remove the old device. + await self.hass.config_entries.async_reload(config_entry.entry_id) + finally: for unsub in unsubs: unsub() diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 31cfb144e2a..3d626710d52 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -16,8 +16,6 @@ LR_ADDON_VERSION = AwesomeVersion("0.5.0") USER_AGENT = {APPLICATION_NAME: HA_VERSION} CONF_ADDON_DEVICE = "device" -CONF_ADDON_EMULATE_HARDWARE = "emulate_hardware" -CONF_ADDON_LOG_LEVEL = "log_level" CONF_ADDON_NETWORK_KEY = "network_key" CONF_ADDON_S0_LEGACY_KEY = "s0_legacy_key" CONF_ADDON_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" @@ -27,6 +25,7 @@ CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key" CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key" CONF_INSTALLER_MODE = "installer_mode" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" +CONF_KEEP_OLD_DEVICES = "keep_old_devices" CONF_NETWORK_KEY = "network_key" CONF_S0_LEGACY_KEY = "s0_legacy_key" CONF_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 2a8e2c6ea2d..d1d4cc94346 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -52,8 +52,6 @@ }, "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%]", @@ -98,9 +96,6 @@ "start_addon": { "title": "The Z-Wave add-on is starting." }, - "usb_confirm": { - "description": "Do you want to set up {name} with the Z-Wave add-on?" - }, "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" @@ -123,7 +118,7 @@ }, "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}”", + "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}”\n\n'<'a href=\"{file_url}\" download=\"{file_name}\"'>'Download backup file'<'/a'>'", "submit": "Try again" }, "choose_serial_port": { @@ -131,6 +126,14 @@ "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "title": "Select your Z-Wave device" + }, + "installation_type": { + "title": "Set up Z-Wave", + "description": "In a few steps, we’re going to set up your Home Assistant Connect ZWA-2. Home Assistant can automatically install and configure the recommended Z-Wave setup, or you can customize it.", + "menu_options": { + "intent_recommended": "Recommended installation", + "intent_custom": "Custom installation" + } } } }, diff --git a/homeassistant/components/zwave_me/strings.json b/homeassistant/components/zwave_me/strings.json index 0c5a1d30976..9bc0d2b8ab7 100644 --- a/homeassistant/components/zwave_me/strings.json +++ b/homeassistant/components/zwave_me/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Input IP address with port and access token of Z-Way server. To get the token go to the Z-Way user interface Smart Home UI > Menu > Settings > Users > Administrator > API token.\n\nExample of connecting to Z-Way running as an add-on:\nURL: {add_on_url}\nToken: {local_token}\n\nExample of connecting to Z-Way in the local network:\nURL: {local_url}\nToken: {local_token}\n\nExample of connecting to Z-Way via remote access find.z-wave.me:\nURL: {find_url}\nToken: {find_token}\n\nExample of connecting to Z-Way with a static public IP address:\nURL: {remote_url}\nToken: {local_token}\n\nWhen connecting via find.z-wave.me you need to use a token with a global scope (log-in to Z-Way via find.z-wave.me for this).", + "description": "Input IP address with port and access token of Z-Way server. To get the token go to the Z-Way user interface Smart Home UI > Menu > Settings > Users > Administrator > API token.\n\nExample of connecting to Z-Way running as an add-on:\nURL: {add_on_url}\nToken: {local_token}\n\nExample of connecting to Z-Way in the local network:\nURL: {local_url}\nToken: {local_token}\n\nExample of connecting to Z-Way via remote access find.z-wave.me:\nURL: {find_url}\nToken: {find_token}\n\nExample of connecting to Z-Way with a static public IP address:\nURL: {remote_url}\nToken: {local_token}\n\nWhen connecting via find.z-wave.me you need to use a token with a global scope (log in to Z-Way via find.z-wave.me for this).", "data": { "url": "[%key:common::config_flow::data::url%]", "token": "[%key:common::config_flow::data::api_token%]" diff --git a/homeassistant/config.py b/homeassistant/config.py index e9089f27662..c3f02539f7d 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -378,7 +378,7 @@ def _get_annotation(item: Any) -> tuple[str, int | str] | None: if not hasattr(item, "__config_file__"): return None - return (getattr(item, "__config_file__"), getattr(item, "__line__", "?")) + return (item.__config_file__, getattr(item, "__line__", "?")) def _get_by_path(data: dict | list, items: list[Hashable]) -> Any: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index c58a33ad68d..c2481ae3fa3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2603,46 +2603,6 @@ class ConfigEntries: ) ) - async def async_forward_entry_setup( - self, entry: ConfigEntry, domain: Platform | str - ) -> bool: - """Forward the setup of an entry to a different component. - - By default an entry is setup with the component it belongs to. If that - component also has related platforms, the component will have to - forward the entry to be setup by that component. - - This method is deprecated and will stop working in Home Assistant 2025.6. - - Instead, await async_forward_entry_setups as it can load - multiple platforms at once and is more efficient since it - does not require a separate import executor job for each platform. - """ - report_usage( - "calls async_forward_entry_setup for " - f"integration, {entry.domain} with title: {entry.title} " - f"and entry_id: {entry.entry_id}, which is deprecated, " - "await async_forward_entry_setups instead", - core_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.6", - ) - if not entry.setup_lock.locked(): - async with entry.setup_lock: - if entry.state is not ConfigEntryState.LOADED: - raise OperationNotAllowed( - f"The config entry '{entry.title}' ({entry.domain}) with " - f"entry_id '{entry.entry_id}' cannot forward setup for " - f"{domain} because it is in state {entry.state}, but needs " - f"to be in the {ConfigEntryState.LOADED} state" - ) - return await self._async_forward_entry_setup(entry, domain, True) - result = await self._async_forward_entry_setup(entry, domain, True) - # If the lock was held when we stated, and it was released during - # the platform setup, it means they did not await the setup call. - if not entry.setup_lock.locked(): - _report_non_awaited_platform_forwards(entry, "async_forward_entry_setup") - return result - async def _async_forward_entry_setup( self, entry: ConfigEntry, @@ -2879,10 +2839,16 @@ class ConfigFlow(ConfigEntryBaseFlow): ) -> None: """Abort if current entries match all data. + Do not abort for the entry that is being updated by the current flow. Requires `already_configured` in strings.json in user visible flows. """ _async_abort_entries_match( - self._async_current_entries(include_ignore=False), match_dict + [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.entry_id != self.context.get("entry_id") + ], + match_dict, ) @callback diff --git a/homeassistant/const.py b/homeassistant/const.py index 931f00803a6..c006cd9dbed 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,8 +24,8 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "3" +MINOR_VERSION: Final = 6 +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, 2) @@ -634,11 +634,20 @@ class UnitOfEnergy(StrEnum): GIGA_CALORIE = "Gcal" +# Reactive energy units +class UnitOfReactiveEnergy(StrEnum): + """Reactive energy units.""" + + VOLT_AMPERE_REACTIVE_HOUR = "varh" + KILO_VOLT_AMPERE_REACTIVE_HOUR = "kvarh" + + # Energy Distance units class UnitOfEnergyDistance(StrEnum): """Energy Distance units.""" KILO_WATT_HOUR_PER_100_KM = "kWh/100km" + WATT_HOUR_PER_KM = "Wh/km" MILES_PER_KILO_WATT_HOUR = "mi/kWh" KM_PER_KILO_WATT_HOUR = "km/kWh" diff --git a/homeassistant/core.py b/homeassistant/core.py index d7535907dfc..afffb883741 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -452,7 +452,7 @@ class HomeAssistant: self.import_executor = InterruptibleThreadPoolExecutor( max_workers=1, thread_name_prefix="ImportExecutor" ) - self.loop_thread_id = getattr(self.loop, "_thread_id") + self.loop_thread_id = self.loop._thread_id # type: ignore[attr-defined] # noqa: SLF001 def verify_event_loop_thread(self, what: str) -> None: """Report and raise if we are not running in the event loop thread.""" diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index 9cd232097a7..f1ba96daae4 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -60,7 +60,6 @@ from .core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from .generated.currencies import HISTORIC_CURRENCIES from .helpers import config_validation as cv, issue_registry as ir from .helpers.entity_values import EntityValues -from .helpers.frame import ReportBehavior, report_usage from .helpers.storage import Store from .helpers.typing import UNDEFINED, UndefinedType from .util import dt as dt_util, location @@ -712,26 +711,6 @@ class Config: else: raise ValueError(f"Received invalid time zone {time_zone_str}") - def set_time_zone(self, time_zone_str: str) -> None: - """Set the time zone. - - This is a legacy method that should not be used in new code. - Use async_set_time_zone instead. - - It will be removed in Home Assistant 2025.6. - """ - report_usage( - "sets the time zone using set_time_zone instead of async_set_time_zone", - core_integration_behavior=ReportBehavior.ERROR, - custom_integration_behavior=ReportBehavior.ERROR, - breaks_in_ha_version="2025.6", - ) - if time_zone := dt_util.get_time_zone(time_zone_str): - self.time_zone = time_zone_str - dt_util.set_default_time_zone(time_zone) - else: - raise ValueError(f"Received invalid time zone {time_zone_str}") - async def _async_update( self, *, @@ -887,17 +866,17 @@ class Config: # pylint: disable-next=import-outside-toplevel from .components.frontend import storage as frontend_store - _, owner_data = await frontend_store.async_user_store( + owner_store = await frontend_store.async_user_store( self.hass, owner.id ) if ( - "language" in owner_data - and "language" in owner_data["language"] + "language" in owner_store.data + and "language" in owner_store.data["language"] ): with suppress(vol.InInvalid): data["language"] = cv.language( - owner_data["language"]["language"] + owner_store.data["language"]["language"] ) # pylint: disable-next=broad-except except Exception: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 9286f9c78f5..ce1c0806b14 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -543,8 +543,17 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.cur_step = result return result - # We pass a copy of the result because we're mutating our version - result = await self.async_finish_flow(flow, result.copy()) + try: + # We pass a copy of the result because we're mutating our version + result = await self.async_finish_flow(flow, result.copy()) + except AbortFlow as err: + result = self._flow_result( + type=FlowResultType.ABORT, + flow_id=flow.flow_id, + handler=flow.handler, + reason=err.reason, + description_placeholders=err.description_placeholders, + ) # _async_finish_flow may change result type, check it again if result["type"] == FlowResultType.FORM: diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index e796625f81c..f5303f09302 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -593,6 +593,12 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "oralb", "manufacturer_id": 220, }, + { + "connectable": True, + "domain": "probe_plus", + "local_name": "FM2*", + "manufacturer_id": 36606, + }, { "connectable": False, "domain": "qingping", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d3fae81d287..2d246f53ca3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -47,6 +47,7 @@ FLOWS = { "airzone", "airzone_cloud", "alarmdecoder", + "alexa_devices", "amberelectric", "ambient_network", "ambient_station", @@ -288,6 +289,7 @@ FLOWS = { "imap", "imeon_inverter", "imgw_pib", + "immich", "improv_ble", "incomfort", "inkbird", @@ -468,6 +470,7 @@ FLOWS = { "p1_monitor", "palazzetti", "panasonic_viera", + "paperless_ngx", "peblar", "peco", "pegel_online", @@ -486,6 +489,7 @@ FLOWS = { "powerfox", "powerwall", "private_ble_device", + "probe_plus", "profiler", "progettihwsw", "prosegur", @@ -536,7 +540,6 @@ FLOWS = { "roon", "rova", "rpi_power", - "rtsp_to_webrtc", "ruckus_unleashed", "russound_rio", "ruuvi_gateway", @@ -575,6 +578,7 @@ FLOWS = { "slimproto", "sma", "smappee", + "smarla", "smart_meter_texas", "smartthings", "smarttub", @@ -736,6 +740,7 @@ FLOWS = { "zerproc", "zeversolar", "zha", + "zimi", "zodiac", "zwave_js", "zwave_me", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 53506ed1748..349c69358ba 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -8,6 +8,20 @@ from __future__ import annotations from typing import Final DHCP: Final[list[dict[str, str | bool]]] = [ + { + "domain": "airthings", + "hostname": "airthings-view", + }, + { + "domain": "airthings", + "hostname": "airthings-hub", + "macaddress": "D0141190*", + }, + { + "domain": "airthings", + "hostname": "airthings-hub", + "macaddress": "70B3D52A0*", + }, { "domain": "airzone", "macaddress": "E84F25*", @@ -94,6 +108,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "bond-*", "macaddress": "F44E38*", }, + { + "domain": "bosch_alarm", + "macaddress": "000463*", + }, { "domain": "broadlink", "registered_devices": True, @@ -258,6 +276,21 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "guardian*", "macaddress": "30AEA4*", }, + { + "domain": "home_connect", + "hostname": "balay-*", + "macaddress": "C8D778*", + }, + { + "domain": "home_connect", + "hostname": "(balay|bosch|neff|siemens)-*", + "macaddress": "68A40E*", + }, + { + "domain": "home_connect", + "hostname": "(siemens|neff)-*", + "macaddress": "38B4D3*", + }, { "domain": "homewizard", "registered_devices": True, @@ -311,6 +344,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "polisy*", "macaddress": "000DB9*", }, + { + "domain": "knocki", + "hostname": "knc*", + }, { "domain": "lamarzocco", "registered_devices": True, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d05944ce628..846a5c74ddb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -207,6 +207,12 @@ "amazon": { "name": "Amazon", "integrations": { + "alexa_devices": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Alexa Devices" + }, "amazon_polly": { "integration_type": "hub", "config_flow": false, @@ -2959,6 +2965,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "immich": { + "name": "Immich", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "improv_ble": { "name": "Improv via BLE", "integration_type": "device", @@ -3181,6 +3193,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "kaiser_nienhaus": { + "name": "Kaiser Nienhaus", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "kaiterra": { "name": "Kaiterra", "integration_type": "hub", @@ -4837,6 +4854,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "paperless_ngx": { + "name": "Paperless-ngx", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "pcs_lighting": { "name": "PCS Lighting", "integration_type": "virtual", @@ -5037,6 +5060,12 @@ "config_flow": true, "iot_class": "local_push" }, + "probe_plus": { + "name": "Probe Plus", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "profiler": { "name": "Profiler", "integration_type": "hub", @@ -5576,12 +5605,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "rtsp_to_webrtc": { - "name": "RTSPtoWebRTC", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" - }, "ruckus_unleashed": { "name": "Ruckus", "integration_type": "hub", @@ -5844,10 +5867,18 @@ "iot_class": "local_push" }, "shelly": { - "name": "Shelly", - "integration_type": "device", - "config_flow": true, - "iot_class": "local_push" + "name": "shelly", + "integrations": { + "shelly": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push", + "name": "Shelly" + } + }, + "iot_standards": [ + "zwave" + ] }, "shodan": { "name": "Shodan", @@ -6005,6 +6036,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "smarla": { + "name": "Swing2Sleep Smarla", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_push" + }, "smart_blinds": { "name": "Smartblinds", "integration_type": "virtual", @@ -7630,6 +7667,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "zimi": { + "name": "zimi", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "zodiac": { "integration_type": "hub", "config_flow": true, diff --git a/homeassistant/generated/languages.py b/homeassistant/generated/languages.py index 7e56952f7a5..86d8c93d1ff 100644 --- a/homeassistant/generated/languages.py +++ b/homeassistant/generated/languages.py @@ -57,6 +57,7 @@ LANGUAGES = { "ru", "sk", "sl", + "sq", "sr", "sr-Latn", "sv", @@ -109,6 +110,7 @@ NATIVE_ENTITY_IDS = { "ro", "sk", "sl", + "sq", "sr-Latn", "sv", "tr", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 38f90663601..e675a0bb237 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -128,6 +128,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX Luna": { + "always_discover": True, + "domain": "lifx", + }, "LIFX Mini": { "always_discover": True, "domain": "lifx", @@ -861,11 +865,6 @@ ZEROCONF = { "domain": "soundtouch", }, ], - "_spotify-connect._tcp.local.": [ - { - "domain": "spotify", - }, - ], "_ssh._tcp.local.": [ { "domain": "smappee", diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index fa2dd42589b..fbdf2dce7b1 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -42,8 +42,6 @@ from homeassistant.const import ( ENTITY_MATCH_ANY, STATE_UNAVAILABLE, STATE_UNKNOWN, - SUN_EVENT_SUNRISE, - SUN_EVENT_SUNSET, WEEKDAYS, ) from homeassistant.core import HomeAssistant, State, callback @@ -60,7 +58,6 @@ from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from . import config_validation as cv, entity_registry as er -from .sun import get_astral_event_date from .template import Template, render_complex from .trace import ( TraceElement, @@ -85,7 +82,6 @@ _PLATFORM_ALIASES = { "numeric_state": None, "or": None, "state": None, - "sun": None, "template": None, "time": None, "trigger": None, @@ -98,12 +94,7 @@ INPUT_ENTITY_ID = re.compile( class ConditionProtocol(Protocol): - """Define the format of device_condition modules. - - Each module must define either CONDITION_SCHEMA or async_validate_condition_config. - """ - - CONDITION_SCHEMA: vol.Schema + """Define the format of condition modules.""" async def async_validate_condition_config( self, hass: HomeAssistant, config: ConfigType @@ -655,105 +646,6 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: return if_state -def sun( - hass: HomeAssistant, - before: str | None = None, - after: str | None = None, - before_offset: timedelta | None = None, - after_offset: timedelta | None = None, -) -> bool: - """Test if current time matches sun requirements.""" - utcnow = dt_util.utcnow() - today = dt_util.as_local(utcnow).date() - before_offset = before_offset or timedelta(0) - after_offset = after_offset or timedelta(0) - - sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today) - sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) - - has_sunrise_condition = SUN_EVENT_SUNRISE in (before, after) - has_sunset_condition = SUN_EVENT_SUNSET in (before, after) - - after_sunrise = today > dt_util.as_local(cast(datetime, sunrise)).date() - if after_sunrise and has_sunrise_condition: - tomorrow = today + timedelta(days=1) - sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow) - - after_sunset = today > dt_util.as_local(cast(datetime, sunset)).date() - if after_sunset and has_sunset_condition: - tomorrow = today + timedelta(days=1) - sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow) - - # Special case: before sunrise OR after sunset - # This will handle the very rare case in the polar region when the sun rises/sets - # but does not set/rise. - # However this entire condition does not handle those full days of darkness - # or light, the following should be used instead: - # - # condition: - # condition: state - # entity_id: sun.sun - # state: 'above_horizon' (or 'below_horizon') - # - if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET: - wanted_time_before = cast(datetime, sunrise) + before_offset - condition_trace_update_result(wanted_time_before=wanted_time_before) - wanted_time_after = cast(datetime, sunset) + after_offset - condition_trace_update_result(wanted_time_after=wanted_time_after) - return utcnow < wanted_time_before or utcnow > wanted_time_after - - if sunrise is None and has_sunrise_condition: - # There is no sunrise today - condition_trace_set_result(False, message="no sunrise today") - return False - - if sunset is None and has_sunset_condition: - # There is no sunset today - condition_trace_set_result(False, message="no sunset today") - return False - - if before == SUN_EVENT_SUNRISE: - wanted_time_before = cast(datetime, sunrise) + before_offset - condition_trace_update_result(wanted_time_before=wanted_time_before) - if utcnow > wanted_time_before: - return False - - if before == SUN_EVENT_SUNSET: - wanted_time_before = cast(datetime, sunset) + before_offset - condition_trace_update_result(wanted_time_before=wanted_time_before) - if utcnow > wanted_time_before: - return False - - if after == SUN_EVENT_SUNRISE: - wanted_time_after = cast(datetime, sunrise) + after_offset - condition_trace_update_result(wanted_time_after=wanted_time_after) - if utcnow < wanted_time_after: - return False - - if after == SUN_EVENT_SUNSET: - wanted_time_after = cast(datetime, sunset) + after_offset - condition_trace_update_result(wanted_time_after=wanted_time_after) - if utcnow < wanted_time_after: - return False - - return True - - -def sun_from_config(config: ConfigType) -> ConditionCheckerType: - """Wrap action method with sun based condition.""" - before = config.get("before") - after = config.get("after") - before_offset = config.get("before_offset") - after_offset = config.get("after_offset") - - @trace_condition_function - def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - """Validate time based if-condition.""" - return sun(hass, before, after, before_offset, after_offset) - - return sun_if - - def template( hass: HomeAssistant, value_template: Template, variables: TemplateVarsType = None ) -> bool: @@ -1054,7 +946,7 @@ async def async_validate_condition_config( return config platform = await _async_get_condition_platform(hass, config) - if platform is not None and hasattr(platform, "async_validate_condition_config"): + if platform is not None: return await platform.async_validate_condition_config(hass, config) if platform is None and condition in ("numeric_state", "state"): validator = cast( diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 1cff90031c2..1671e8e2cc2 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -22,6 +22,7 @@ import time from typing import Any, cast from aiohttp import ClientError, ClientResponseError, client, web +from habluetooth import BluetoothServiceInfoBleak import jwt import voluptuous as vol from yarl import URL @@ -34,6 +35,9 @@ from homeassistant.util.hass_dict import HassKey from . import http from .aiohttp_client import async_get_clientsession from .network import NoURLAvailableError +from .service_info.dhcp import DhcpServiceInfo +from .service_info.ssdp import SsdpServiceInfo +from .service_info.zeroconf import ZeroconfServiceInfo _LOGGER = logging.getLogger(__name__) @@ -493,6 +497,45 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): """Handle a flow start.""" return await self.async_step_pick_implementation(user_input) + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> config_entries.ConfigFlowResult: + """Handle a flow initialized by Bluetooth discovery.""" + return await self.async_step_oauth_discovery() + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> config_entries.ConfigFlowResult: + """Handle a flow initialized by DHCP discovery.""" + return await self.async_step_oauth_discovery() + + async def async_step_homekit( + self, discovery_info: ZeroconfServiceInfo + ) -> config_entries.ConfigFlowResult: + """Handle a flow initialized by Homekit discovery.""" + return await self.async_step_oauth_discovery() + + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> config_entries.ConfigFlowResult: + """Handle a flow initialized by SSDP discovery.""" + return await self.async_step_oauth_discovery() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> config_entries.ConfigFlowResult: + """Handle a flow initialized by Zeroconf discovery.""" + return await self.async_step_oauth_discovery() + + async def async_step_oauth_discovery( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Handle a flow initialized by a discovery method.""" + if user_input is not None: + return await self.async_step_user() + await self._async_handle_discovery_without_unique_id() + return self.async_show_form(step_id="oauth_discovery") + @classmethod def async_register_implementation( cls, hass: HomeAssistant, local_impl: LocalOAuth2Implementation diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 0ce2c9e02e0..31a3e365071 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1084,10 +1084,13 @@ def renamed( return validator +type ValueSchemas = dict[Hashable, VolSchemaType | Callable[[Any], dict[str, Any]]] + + def key_value_schemas( key: str, - value_schemas: dict[Hashable, VolSchemaType | Callable[[Any], dict[str, Any]]], - default_schema: VolSchemaType | None = None, + value_schemas: ValueSchemas, + default_schema: VolSchemaType | Callable[[Any], dict[str, Any]] | None = None, default_description: str | None = None, ) -> Callable[[Any], dict[Hashable, Any]]: """Create a validator that validates based on a value for specific key. @@ -1735,25 +1738,41 @@ CONDITION_SHORTHAND_SCHEMA = vol.Schema( } ) +BUILT_IN_CONDITIONS: ValueSchemas = { + "and": AND_CONDITION_SCHEMA, + "device": DEVICE_CONDITION_SCHEMA, + "not": NOT_CONDITION_SCHEMA, + "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, + "or": OR_CONDITION_SCHEMA, + "state": STATE_CONDITION_SCHEMA, + "template": TEMPLATE_CONDITION_SCHEMA, + "time": TIME_CONDITION_SCHEMA, + "trigger": TRIGGER_CONDITION_SCHEMA, + "zone": ZONE_CONDITION_SCHEMA, +} + + +# This is first round of validation, we don't want to mutate the config here already, +# just ensure basics as condition type and alias are there. +def _base_condition_validator(value: Any) -> Any: + vol.Schema( + { + **CONDITION_BASE_SCHEMA, + CONF_CONDITION: vol.NotIn(BUILT_IN_CONDITIONS), + }, + extra=vol.ALLOW_EXTRA, + )(value) + return value + + CONDITION_SCHEMA: vol.Schema = vol.Schema( vol.Any( vol.All( expand_condition_shorthand, key_value_schemas( CONF_CONDITION, - { - "and": AND_CONDITION_SCHEMA, - "device": DEVICE_CONDITION_SCHEMA, - "not": NOT_CONDITION_SCHEMA, - "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, - "or": OR_CONDITION_SCHEMA, - "state": STATE_CONDITION_SCHEMA, - "sun": SUN_CONDITION_SCHEMA, - "template": TEMPLATE_CONDITION_SCHEMA, - "time": TIME_CONDITION_SCHEMA, - "trigger": TRIGGER_CONDITION_SCHEMA, - "zone": ZONE_CONDITION_SCHEMA, - }, + BUILT_IN_CONDITIONS, + _base_condition_validator, ), ), dynamic_template_condition, @@ -1780,20 +1799,11 @@ CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema( expand_condition_shorthand, key_value_schemas( CONF_CONDITION, - { - "and": AND_CONDITION_SCHEMA, - "device": DEVICE_CONDITION_SCHEMA, - "not": NOT_CONDITION_SCHEMA, - "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, - "or": OR_CONDITION_SCHEMA, - "state": STATE_CONDITION_SCHEMA, - "sun": SUN_CONDITION_SCHEMA, - "template": TEMPLATE_CONDITION_SCHEMA, - "time": TIME_CONDITION_SCHEMA, - "trigger": TRIGGER_CONDITION_SCHEMA, - "zone": ZONE_CONDITION_SCHEMA, - }, - dynamic_template_condition_action, + BUILT_IN_CONDITIONS, + vol.Any( + dynamic_template_condition_action, + _base_condition_validator, + ), "a list of conditions or a valid template", ), ) @@ -1852,7 +1862,7 @@ def _base_trigger_list_flatten(triggers: list[Any]) -> list[Any]: return flatlist -# This is first round of validation, we don't want to process the config here already, +# This is first round of validation, we don't want to mutate the config here already, # just ensure basics as platform and ID are there. def _base_trigger_validator(value: Any) -> Any: _base_trigger_validator_schema(value) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index 16212422236..f1404bb068b 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -62,18 +62,20 @@ def async_device_info_to_link_from_device_id( def async_remove_stale_devices_links_keep_entity_device( hass: HomeAssistant, entry_id: str, - source_entity_id_or_uuid: str, + source_entity_id_or_uuid: str | None, ) -> None: - """Remove the link between stale devices and a configuration entry. + """Remove entry_id from all devices except that of source_entity_id_or_uuid. - Only the device passed in the source_entity_id_or_uuid parameter - linked to the configuration entry will be maintained. + Also moves all entities linked to the entry_id to the device of + source_entity_id_or_uuid. """ async_remove_stale_devices_links_keep_current_device( hass=hass, entry_id=entry_id, - current_device_id=async_entity_id_to_device_id(hass, source_entity_id_or_uuid), + current_device_id=async_entity_id_to_device_id(hass, source_entity_id_or_uuid) + if source_entity_id_or_uuid + else None, ) @@ -83,13 +85,17 @@ def async_remove_stale_devices_links_keep_current_device( entry_id: str, current_device_id: str | None, ) -> None: - """Remove the link between stale devices and a configuration entry. - - Only the device passed in the current_device_id parameter linked to - the configuration entry will be maintained. - """ + """Remove entry_id from all devices except current_device_id.""" dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + # Make sure all entities are linked to the correct device + for entity in ent_reg.entities.get_entries_for_config_entry_id(entry_id): + if entity.device_id == current_device_id: + continue + ent_reg.async_update_entity(entity.entity_id, device_id=current_device_id) + # Removes all devices from the config entry that are not the same as the current device for device in dev_reg.devices.get_devices_for_config_entry_id(entry_id): if device.id == current_device_id: diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index a80e74e7eb2..161e1205d4f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -397,11 +397,11 @@ class DeletedDeviceEntry: config_entries: set[str] = attr.ib() config_entries_subentries: dict[str, set[str | None]] = attr.ib() connections: set[tuple[str, str]] = attr.ib() - identifiers: set[tuple[str, str]] = attr.ib() + created_at: datetime = attr.ib() id: str = attr.ib() + identifiers: set[tuple[str, str]] = attr.ib() + modified_at: datetime = attr.ib() orphaned_timestamp: float | None = attr.ib() - created_at: datetime = attr.ib(factory=utcnow) - modified_at: datetime = attr.ib(factory=utcnow) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) def to_device_entry( @@ -440,8 +440,8 @@ class DeletedDeviceEntry: "created_at": self.created_at, "identifiers": list(self.identifiers), "id": self.id, - "orphaned_timestamp": self.orphaned_timestamp, "modified_at": self.modified_at, + "orphaned_timestamp": self.orphaned_timestamp, } ) ) @@ -1244,6 +1244,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): created_at=device.created_at, identifiers=device.identifiers, id=device.id, + modified_at=utcnow(), orphaned_timestamp=None, ) for other_device in list(self.devices.values()): diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index a3edf6bb64f..8b13ee2409a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -381,7 +381,7 @@ class CachedProperties(type): for parent in cls.__mro__[:0:-1]: if "_CachedProperties__cached_properties" not in parent.__dict__: continue - cached_properties = getattr(parent, "_CachedProperties__cached_properties") + cached_properties = getattr(parent, "_CachedProperties__cached_properties") # noqa: B009 for property_name in cached_properties: if property_name in seen_props: continue diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 02508e9ee9e..94dd97a9af9 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -29,20 +29,27 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform +from homeassistant.util.hass_dict import HassKey -from . import config_validation as cv, discovery, entity, service -from .entity_platform import EntityPlatform +from . import ( + config_validation as cv, + device_registry as dr, + discovery, + entity, + entity_registry as er, + service, +) +from .entity_platform import EntityPlatform, async_calculate_suggested_object_id from .typing import ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) -DATA_INSTANCES = "entity_components" +DATA_INSTANCES: HassKey[dict[str, EntityComponent]] = HassKey("entity_components") @bind_hass async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: """Trigger an update for an entity.""" domain = entity_id.partition(".")[0] - entity_comp: EntityComponent[entity.Entity] | None entity_comp = hass.data.get(DATA_INSTANCES, {}).get(domain) if entity_comp is None: @@ -60,6 +67,36 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: await entity_obj.async_update_ha_state(True) +@callback +def async_get_entity_suggested_object_id( + hass: HomeAssistant, entity_id: str +) -> str | None: + """Get the suggested object id for an entity. + + Raises HomeAssistantError if the entity is not in the registry or + is not backed by an object. + """ + entity_registry = er.async_get(hass) + if not (entity_entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError(f"Entity {entity_id} is not in the registry.") + + domain = entity_id.partition(".")[0] + + if entity_entry.name: + return entity_entry.name + + if entity_entry.suggested_object_id: + return entity_entry.suggested_object_id + + entity_comp = hass.data.get(DATA_INSTANCES, {}).get(domain) + if not (entity_obj := entity_comp.get_entity(entity_id) if entity_comp else None): + raise HomeAssistantError(f"Entity {entity_id} has no object.") + device: dr.DeviceEntry | None = None + if device_id := entity_entry.device_id: + device = dr.async_get(hass).async_get(device_id) + return async_calculate_suggested_object_id(entity_obj, device) + + class EntityComponent[_EntityT: entity.Entity = entity.Entity]: """The EntityComponent manages platforms that manage entities. @@ -95,7 +132,7 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]: self.async_add_entities = domain_platform.async_add_entities self.add_entities = domain_platform.add_entities self._entities: dict[str, entity.Entity] = domain_platform.domain_entities - hass.data.setdefault(DATA_INSTANCES, {})[domain] = self + hass.data.setdefault(DATA_INSTANCES, {})[domain] = self # type: ignore[assignment] @property def entities(self) -> Iterable[_EntityT]: diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index d4fa567e929..0423a1979bc 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -843,24 +843,23 @@ class EntityPlatform: else: device = None + calculated_object_id: str | None = None # An entity may suggest the entity_id by setting entity_id itself suggested_entity_id: str | None = entity.entity_id if suggested_entity_id is not None: suggested_object_id = split_entity_id(entity.entity_id)[1] - else: - if device and entity.has_entity_name: - device_name = device.name_by_user or device.name - if entity.use_device_name: - suggested_object_id = device_name - else: - suggested_object_id = ( - f"{device_name} {entity.suggested_object_id}" - ) - if not suggested_object_id: - suggested_object_id = entity.suggested_object_id - - if self.entity_namespace is not None: - suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" + if self.entity_namespace is not None: + suggested_object_id = ( + f"{self.entity_namespace} {suggested_object_id}" + ) + if not registered_entity_id and suggested_entity_id is None: + # Do not bother working out a suggested_object_id + # if the entity is already registered as it will + # be ignored. + # + calculated_object_id = async_calculate_suggested_object_id( + entity, device + ) disabled_by: RegistryEntryDisabler | None = None if not entity.entity_registry_enabled_default: @@ -874,6 +873,7 @@ class EntityPlatform: self.domain, self.platform_name, entity.unique_id, + calculated_object_id=calculated_object_id, capabilities=entity.capability_attributes, config_entry=self.config_entry, config_subentry_id=config_subentry_id, @@ -1117,6 +1117,27 @@ class EntityPlatform: await asyncio.gather(*tasks) +@callback +def async_calculate_suggested_object_id( + entity: Entity, device: dev_reg.DeviceEntry | None +) -> str | None: + """Calculate the suggested object ID for an entity.""" + calculated_object_id: str | None = None + if device and entity.has_entity_name: + device_name = device.name_by_user or device.name + if entity.use_device_name: + calculated_object_id = device_name + else: + calculated_object_id = f"{device_name} {entity.suggested_object_id}" + if not calculated_object_id: + calculated_object_id = entity.suggested_object_id + + if (platform := entity.platform) and platform.entity_namespace is not None: + calculated_object_id = f"{platform.entity_namespace} {calculated_object_id}" + + return calculated_object_id + + current_platform: ContextVar[EntityPlatform | None] = ContextVar( "current_platform", default=None ) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 78a65acf290..72689bc4997 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -79,7 +79,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 16 +STORAGE_VERSION_MINOR = 17 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -198,6 +198,7 @@ class RegistryEntry: original_device_class: str | None = attr.ib() original_icon: str | None = attr.ib() original_name: str | None = attr.ib() + suggested_object_id: str | None = attr.ib() supported_features: int = attr.ib() translation_key: str | None = attr.ib() unit_of_measurement: str | None = attr.ib() @@ -359,6 +360,7 @@ class RegistryEntry: "original_icon": self.original_icon, "original_name": self.original_name, "platform": self.platform, + "suggested_object_id": self.suggested_object_id, "supported_features": self.supported_features, "translation_key": self.translation_key, "unique_id": self.unique_id, @@ -406,11 +408,12 @@ class DeletedRegistryEntry: platform: str = attr.ib() config_entry_id: str | None = attr.ib() config_subentry_id: str | None = attr.ib() + created_at: datetime = attr.ib() domain: str = attr.ib(init=False, repr=False) id: str = attr.ib() + modified_at: datetime = attr.ib() orphaned_timestamp: float | None = attr.ib() - created_at: datetime = attr.ib(factory=utcnow) - modified_at: datetime = attr.ib(factory=utcnow) + _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @domain.default @@ -548,6 +551,11 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entity in data["deleted_entities"]: entity["config_subentry_id"] = None + if old_minor_version < 17: + # Version 1.17 adds suggested_object_id + for entity in data["entities"]: + entity["suggested_object_id"] = None + if old_major_version > 1: raise NotImplementedError return data @@ -806,6 +814,9 @@ class EntityRegistry(BaseRegistry): self, domain: str, suggested_object_id: str, + *, + current_entity_id: str | None = None, + reserved_entity_ids: set[str] | None = None, ) -> str: """Generate an entity ID that does not conflict. @@ -819,7 +830,10 @@ class EntityRegistry(BaseRegistry): test_string = preferred_string[:MAX_LENGTH_STATE_ENTITY_ID] tries = 1 - while not self._entity_id_available(test_string): + while ( + not self._entity_id_available(test_string) + and test_string != current_entity_id + ) or (reserved_entity_ids and test_string in reserved_entity_ids): tries += 1 len_suffix = len(str(tries)) + 1 test_string = ( @@ -836,6 +850,7 @@ class EntityRegistry(BaseRegistry): unique_id: str, *, # To influence entity ID generation + calculated_object_id: str | None = None, suggested_object_id: str | None = None, # To disable or hide an entity if it gets created disabled_by: RegistryEntryDisabler | None = None, @@ -908,7 +923,7 @@ class EntityRegistry(BaseRegistry): entity_id = self.async_generate_entity_id( domain, - suggested_object_id or f"{platform}_{unique_id}", + suggested_object_id or calculated_object_id or f"{platform}_{unique_id}", ) if ( @@ -942,6 +957,7 @@ class EntityRegistry(BaseRegistry): original_icon=none_if_undefined(original_icon), original_name=none_if_undefined(original_name), platform=platform, + suggested_object_id=suggested_object_id, supported_features=none_if_undefined(supported_features) or 0, translation_key=none_if_undefined(translation_key), unique_id=unique_id, @@ -964,6 +980,14 @@ class EntityRegistry(BaseRegistry): def async_remove(self, entity_id: str) -> None: """Remove an entity from registry.""" self.hass.verify_event_loop_thread("entity_registry.async_remove") + if entity_id not in self.entities: + # Allow attempts to remove an entity which does not exist. If this is + # not allowed, there will be races during cleanup where we iterate over + # lists of entities to remove, but there are listeners for entity + # registry events which delete entities at the same time. + # For example, if we clean up entities A and B, there might be a listener + # which deletes entity B when entity A is being removed. + return entity = self.entities.pop(entity_id) config_entry_id = entity.config_entry_id key = (entity.domain, entity.platform, entity.unique_id) @@ -975,6 +999,7 @@ class EntityRegistry(BaseRegistry): created_at=entity.created_at, entity_id=entity_id, id=entity.id, + modified_at=utcnow(), orphaned_timestamp=orphaned_timestamp, platform=entity.platform, unique_id=entity.unique_id, @@ -1378,6 +1403,7 @@ class EntityRegistry(BaseRegistry): original_icon=entity["original_icon"], original_name=entity["original_name"], platform=entity["platform"], + suggested_object_id=entity["suggested_object_id"], supported_features=entity["supported_features"], translation_key=entity["translation_key"], unique_id=entity["unique_id"], diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py new file mode 100644 index 00000000000..61bb0bcd45d --- /dev/null +++ b/homeassistant/helpers/helper_integration.py @@ -0,0 +1,113 @@ +"""Helpers for helper integrations.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any + +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, valid_entity_id + +from . import device_registry as dr, entity_registry as er +from .event import async_track_entity_registry_updated_event + + +def async_handle_source_entity_changes( + hass: HomeAssistant, + *, + helper_config_entry_id: str, + set_source_entity_id_or_uuid: Callable[[str], None], + source_device_id: str | None, + source_entity_id_or_uuid: str, + source_entity_removed: Callable[[], Coroutine[Any, Any, None]], +) -> CALLBACK_TYPE: + """Handle changes to a helper entity's source entity. + + The following changes are handled: + - Entity removal: If the source entity is removed, the helper config entry + is removed, and the helper entity is cleaned up. + - Entity ID changed: If the source entity's entity ID changes and the source + entity is identified by an entity ID, the set_source_entity_id_or_uuid is + called. If the source entity is identified by a UUID, the helper config entry + is reloaded. + - Source entity moved to another device: The helper entity is updated to link + to the new device, and the helper config entry removed from the old device + and added to the new device. Then the helper config entry is reloaded. + - Source entity removed from the device: The helper entity is updated to link + to no device, and the helper config entry removed from the old device. Then + the helper config entry is reloaded. + + :param set_source_entity_id_or_uuid: A function which updates the source entity + ID or UUID, e.g., in the helper config entry options. + :param source_entity_removed: A function which is called when the source entity + is removed. This can be used to clean up any resources related to the source + entity or ask the user to select a new source entity. + """ + + async def async_registry_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + nonlocal source_device_id + + data = event.data + if data["action"] == "remove": + await source_entity_removed() + + if data["action"] != "update": + return + + if "entity_id" in data["changes"]: + # Entity_id changed, update or reload the config entry + if valid_entity_id(source_entity_id_or_uuid): + # If the entity is pointed to by an entity ID, update the entry + set_source_entity_id_or_uuid(data["entity_id"]) + else: + await hass.config_entries.async_reload(helper_config_entry_id) + + if not source_device_id or "device_id" not in data["changes"]: + return + + # Handle the source entity being moved to a different device or removed + # from the device + if ( + not (source_entity_entry := entity_registry.async_get(data["entity_id"])) + or not device_registry.async_get(source_device_id) + or source_entity_entry.device_id == source_device_id + ): + # No need to do any cleanup + return + + # The source entity has been moved to a different device, update the helper + # entities to link to the new device and the helper device to include the + # helper config entry + for helper_entity in entity_registry.entities.get_entries_for_config_entry_id( + helper_config_entry_id + ): + # Update the helper entity to link to the new device (or no device) + entity_registry.async_update_entity( + helper_entity.entity_id, device_id=source_entity_entry.device_id + ) + + if source_entity_entry.device_id is not None: + device_registry.async_update_device( + source_entity_entry.device_id, + add_config_entry_id=helper_config_entry_id, + ) + + device_registry.async_update_device( + source_device_id, remove_config_entry_id=helper_config_entry_id + ) + source_device_id = source_entity_entry.device_id + + # Reload the config entry so the helper entity is recreated with + # correct device info + await hass.config_entries.async_reload(helper_config_entry_id) + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + source_entity_id = er.async_validate_entity_id( + entity_registry, source_entity_id_or_uuid + ) + return async_track_entity_registry_updated_event( + hass, source_entity_id, async_registry_updated + ) diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index af8c4c6402d..93d9a3d06f1 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -214,6 +214,11 @@ class SchemaCommonFlowHandler: and key.description.get("advanced") and not self._handler.show_advanced_options ) + and not ( + # don't remove read_only keys + isinstance(data_schema.schema[key], selector.Selector) + and data_schema.schema[key].config.get("read_only") + ) ): # Key not present, delete keys old value (if present) too values.pop(key.schema, None) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index f2c76d1d019..2d7fd51cac7 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -131,6 +131,19 @@ def _validate_supported_features(supported_features: int | list[str]) -> int: return feature_mask +BASE_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("read_only"): bool, + } +) + + +class BaseSelectorConfig(TypedDict, total=False): + """Class to common options of all selectors.""" + + read_only: bool + + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration that provided the entity @@ -183,7 +196,7 @@ class DeviceFilterSelectorConfig(TypedDict, total=False): model_id: str -class ActionSelectorConfig(TypedDict): +class ActionSelectorConfig(BaseSelectorConfig): """Class to represent an action selector config.""" @@ -193,7 +206,7 @@ class ActionSelector(Selector[ActionSelectorConfig]): selector_type = "action" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: ActionSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -204,7 +217,7 @@ class ActionSelector(Selector[ActionSelectorConfig]): return data -class AddonSelectorConfig(TypedDict, total=False): +class AddonSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an addon selector config.""" name: str @@ -217,7 +230,7 @@ class AddonSelector(Selector[AddonSelectorConfig]): selector_type = "addon" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("name"): str, vol.Optional("slug"): str, @@ -234,7 +247,7 @@ class AddonSelector(Selector[AddonSelectorConfig]): return addon -class AreaSelectorConfig(TypedDict, total=False): +class AreaSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an area selector config.""" entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] @@ -248,7 +261,7 @@ class AreaSelector(Selector[AreaSelectorConfig]): selector_type = "area" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("entity"): vol.All( cv.ensure_list, @@ -276,7 +289,7 @@ class AreaSelector(Selector[AreaSelectorConfig]): return [vol.Schema(str)(val) for val in data] -class AssistPipelineSelectorConfig(TypedDict, total=False): +class AssistPipelineSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an assist pipeline selector config.""" @@ -286,7 +299,7 @@ class AssistPipelineSelector(Selector[AssistPipelineSelectorConfig]): selector_type = "assist_pipeline" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: AssistPipelineSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -298,7 +311,7 @@ class AssistPipelineSelector(Selector[AssistPipelineSelectorConfig]): return pipeline -class AttributeSelectorConfig(TypedDict, total=False): +class AttributeSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an attribute selector config.""" entity_id: Required[str] @@ -311,7 +324,7 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): selector_type = "attribute" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Required("entity_id"): cv.entity_id, # hide_attributes is used to hide attributes in the frontend. @@ -330,7 +343,7 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): return attribute -class BackupLocationSelectorConfig(TypedDict, total=False): +class BackupLocationSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a backup location selector config.""" @@ -340,7 +353,7 @@ class BackupLocationSelector(Selector[BackupLocationSelectorConfig]): selector_type = "backup_location" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: BackupLocationSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -352,7 +365,7 @@ class BackupLocationSelector(Selector[BackupLocationSelectorConfig]): return name -class BooleanSelectorConfig(TypedDict): +class BooleanSelectorConfig(BaseSelectorConfig): """Class to represent a boolean selector config.""" @@ -362,7 +375,7 @@ class BooleanSelector(Selector[BooleanSelectorConfig]): selector_type = "boolean" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: BooleanSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -374,7 +387,7 @@ class BooleanSelector(Selector[BooleanSelectorConfig]): return value -class ColorRGBSelectorConfig(TypedDict): +class ColorRGBSelectorConfig(BaseSelectorConfig): """Class to represent a color RGB selector config.""" @@ -384,7 +397,7 @@ class ColorRGBSelector(Selector[ColorRGBSelectorConfig]): selector_type = "color_rgb" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: ColorRGBSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -396,7 +409,7 @@ class ColorRGBSelector(Selector[ColorRGBSelectorConfig]): return value -class ColorTempSelectorConfig(TypedDict, total=False): +class ColorTempSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a color temp selector config.""" unit: ColorTempSelectorUnit @@ -419,7 +432,7 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): selector_type = "color_temp" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("unit", default=ColorTempSelectorUnit.MIRED): vol.All( vol.Coerce(ColorTempSelectorUnit), lambda val: val.value @@ -456,7 +469,7 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): return value -class ConditionSelectorConfig(TypedDict): +class ConditionSelectorConfig(BaseSelectorConfig): """Class to represent an condition selector config.""" @@ -466,7 +479,7 @@ class ConditionSelector(Selector[ConditionSelectorConfig]): selector_type = "condition" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: ConditionSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -477,7 +490,7 @@ class ConditionSelector(Selector[ConditionSelectorConfig]): return vol.Schema(cv.CONDITIONS_SCHEMA)(data) -class ConfigEntrySelectorConfig(TypedDict, total=False): +class ConfigEntrySelectorConfig(BaseSelectorConfig, total=False): """Class to represent a config entry selector config.""" integration: str @@ -489,7 +502,7 @@ class ConfigEntrySelector(Selector[ConfigEntrySelectorConfig]): selector_type = "config_entry" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("integration"): str, } @@ -505,7 +518,7 @@ class ConfigEntrySelector(Selector[ConfigEntrySelectorConfig]): return config -class ConstantSelectorConfig(TypedDict, total=False): +class ConstantSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a constant selector config.""" label: str @@ -519,7 +532,7 @@ class ConstantSelector(Selector[ConstantSelectorConfig]): selector_type = "constant" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("label"): str, vol.Optional("translation_key"): cv.string, @@ -546,7 +559,7 @@ class QrErrorCorrectionLevel(StrEnum): HIGH = "high" -class QrCodeSelectorConfig(TypedDict, total=False): +class QrCodeSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a QR code selector config.""" data: str @@ -560,7 +573,7 @@ class QrCodeSelector(Selector[QrCodeSelectorConfig]): selector_type = "qr_code" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Required("data"): str, vol.Optional("scale"): int, @@ -580,7 +593,7 @@ class QrCodeSelector(Selector[QrCodeSelectorConfig]): return self.config["data"] -class ConversationAgentSelectorConfig(TypedDict, total=False): +class ConversationAgentSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a conversation agent selector config.""" language: str @@ -592,7 +605,7 @@ class ConversationAgentSelector(Selector[ConversationAgentSelectorConfig]): selector_type = "conversation_agent" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("language"): str, } @@ -608,7 +621,7 @@ class ConversationAgentSelector(Selector[ConversationAgentSelectorConfig]): return agent -class CountrySelectorConfig(TypedDict, total=False): +class CountrySelectorConfig(BaseSelectorConfig, total=False): """Class to represent a country selector config.""" countries: list[str] @@ -621,7 +634,7 @@ class CountrySelector(Selector[CountrySelectorConfig]): selector_type = "country" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("countries"): [str], vol.Optional("no_sort", default=False): cv.boolean, @@ -642,7 +655,7 @@ class CountrySelector(Selector[CountrySelectorConfig]): return country -class DateSelectorConfig(TypedDict): +class DateSelectorConfig(BaseSelectorConfig): """Class to represent a date selector config.""" @@ -652,7 +665,7 @@ class DateSelector(Selector[DateSelectorConfig]): selector_type = "date" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: DateSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -664,7 +677,7 @@ class DateSelector(Selector[DateSelectorConfig]): return data -class DateTimeSelectorConfig(TypedDict): +class DateTimeSelectorConfig(BaseSelectorConfig): """Class to represent a date time selector config.""" @@ -674,7 +687,7 @@ class DateTimeSelector(Selector[DateTimeSelectorConfig]): selector_type = "datetime" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: DateTimeSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -686,7 +699,7 @@ class DateTimeSelector(Selector[DateTimeSelectorConfig]): return data -class DeviceSelectorConfig(DeviceFilterSelectorConfig, total=False): +class DeviceSelectorConfig(BaseSelectorConfig, DeviceFilterSelectorConfig, total=False): """Class to represent a device selector config.""" entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] @@ -700,7 +713,9 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" - CONFIG_SCHEMA = DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.schema + ).extend( { vol.Optional("multiple", default=False): cv.boolean, vol.Optional("filter"): vol.All( @@ -724,7 +739,7 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): return [vol.Schema(str)(val) for val in data] -class DurationSelectorConfig(TypedDict, total=False): +class DurationSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a duration selector config.""" enable_day: bool @@ -738,7 +753,7 @@ class DurationSelector(Selector[DurationSelectorConfig]): selector_type = "duration" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { # Enable day field in frontend. A selection with `days` set is allowed # even if `enable_day` is not set @@ -763,7 +778,7 @@ class DurationSelector(Selector[DurationSelectorConfig]): return cast(dict[str, float], data) -class EntitySelectorConfig(EntityFilterSelectorConfig, total=False): +class EntitySelectorConfig(BaseSelectorConfig, EntityFilterSelectorConfig, total=False): """Class to represent an entity selector config.""" exclude_entities: list[str] @@ -778,7 +793,9 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" - CONFIG_SCHEMA = ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.schema + ).extend( { vol.Optional("exclude_entities"): [str], vol.Optional("include_entities"): [str], @@ -824,7 +841,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): return cast(list, vol.Schema([validate])(data)) # Output is a list -class FloorSelectorConfig(TypedDict, total=False): +class FloorSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an floor selector config.""" entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] @@ -838,7 +855,7 @@ class FloorSelector(Selector[FloorSelectorConfig]): selector_type = "floor" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("entity"): vol.All( cv.ensure_list, @@ -866,7 +883,7 @@ class FloorSelector(Selector[FloorSelectorConfig]): return [vol.Schema(str)(val) for val in data] -class IconSelectorConfig(TypedDict, total=False): +class IconSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an icon selector config.""" placeholder: str @@ -878,7 +895,7 @@ class IconSelector(Selector[IconSelectorConfig]): selector_type = "icon" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( {vol.Optional("placeholder"): str} # Frontend also has a fallbackPath option, this is not used by core ) @@ -893,7 +910,7 @@ class IconSelector(Selector[IconSelectorConfig]): return icon -class LabelSelectorConfig(TypedDict, total=False): +class LabelSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a label selector config.""" multiple: bool @@ -905,7 +922,7 @@ class LabelSelector(Selector[LabelSelectorConfig]): selector_type = "label" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("multiple", default=False): cv.boolean, } @@ -925,7 +942,7 @@ class LabelSelector(Selector[LabelSelectorConfig]): return [vol.Schema(str)(val) for val in data] -class LanguageSelectorConfig(TypedDict, total=False): +class LanguageSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an language selector config.""" languages: list[str] @@ -939,7 +956,7 @@ class LanguageSelector(Selector[LanguageSelectorConfig]): selector_type = "language" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("languages"): [str], vol.Optional("native_name", default=False): cv.boolean, @@ -959,7 +976,7 @@ class LanguageSelector(Selector[LanguageSelectorConfig]): return language -class LocationSelectorConfig(TypedDict, total=False): +class LocationSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a location selector config.""" radius: bool @@ -972,7 +989,7 @@ class LocationSelector(Selector[LocationSelectorConfig]): selector_type = "location" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( {vol.Optional("radius"): bool, vol.Optional("icon"): str} ) DATA_SCHEMA = vol.Schema( @@ -993,7 +1010,7 @@ class LocationSelector(Selector[LocationSelectorConfig]): return location -class MediaSelectorConfig(TypedDict): +class MediaSelectorConfig(BaseSelectorConfig): """Class to represent a media selector config.""" @@ -1003,7 +1020,7 @@ class MediaSelector(Selector[MediaSelectorConfig]): selector_type = "media" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA DATA_SCHEMA = vol.Schema( { # Although marked as optional in frontend, this field is required @@ -1026,7 +1043,7 @@ class MediaSelector(Selector[MediaSelectorConfig]): return media -class NumberSelectorConfig(TypedDict, total=False): +class NumberSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a number selector config.""" min: float @@ -1061,7 +1078,7 @@ class NumberSelector(Selector[NumberSelectorConfig]): selector_type = "number" CONFIG_SCHEMA = vol.All( - vol.Schema( + BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("min"): vol.Coerce(float), vol.Optional("max"): vol.Coerce(float), @@ -1096,7 +1113,7 @@ class NumberSelector(Selector[NumberSelectorConfig]): return value -class ObjectSelectorConfig(TypedDict): +class ObjectSelectorConfig(BaseSelectorConfig): """Class to represent an object selector config.""" @@ -1106,7 +1123,7 @@ class ObjectSelector(Selector[ObjectSelectorConfig]): selector_type = "object" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: ObjectSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -1142,7 +1159,7 @@ class SelectSelectorMode(StrEnum): DROPDOWN = "dropdown" -class SelectSelectorConfig(TypedDict, total=False): +class SelectSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a select selector config.""" options: Required[Sequence[SelectOptionDict] | Sequence[str]] @@ -1159,7 +1176,7 @@ class SelectSelector(Selector[SelectSelectorConfig]): selector_type = "select" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Required("options"): vol.All(vol.Any([str], [select_option])), vol.Optional("multiple", default=False): cv.boolean, @@ -1199,14 +1216,14 @@ class SelectSelector(Selector[SelectSelectorConfig]): return [parent_schema(vol.Schema(str)(val)) for val in data] -class TargetSelectorConfig(TypedDict, total=False): +class TargetSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a target selector config.""" entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] -class StateSelectorConfig(TypedDict, total=False): +class StateSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an state selector config.""" entity_id: Required[str] @@ -1218,7 +1235,7 @@ class StateSelector(Selector[StateSelectorConfig]): selector_type = "state" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Required("entity_id"): cv.entity_id, # The attribute to filter on, is currently deliberately not @@ -1248,7 +1265,7 @@ class TargetSelector(Selector[TargetSelectorConfig]): selector_type = "target" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("entity"): vol.All( cv.ensure_list, @@ -1273,7 +1290,7 @@ class TargetSelector(Selector[TargetSelectorConfig]): return target -class TemplateSelectorConfig(TypedDict): +class TemplateSelectorConfig(BaseSelectorConfig): """Class to represent an template selector config.""" @@ -1283,7 +1300,7 @@ class TemplateSelector(Selector[TemplateSelectorConfig]): selector_type = "template" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: TemplateSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -1295,7 +1312,7 @@ class TemplateSelector(Selector[TemplateSelectorConfig]): return template.template -class TextSelectorConfig(TypedDict, total=False): +class TextSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a text selector config.""" multiline: bool @@ -1330,7 +1347,7 @@ class TextSelector(Selector[TextSelectorConfig]): selector_type = "text" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("multiline", default=False): bool, vol.Optional("prefix"): str, @@ -1359,7 +1376,7 @@ class TextSelector(Selector[TextSelectorConfig]): return [vol.Schema(str)(val) for val in data] -class ThemeSelectorConfig(TypedDict): +class ThemeSelectorConfig(BaseSelectorConfig): """Class to represent a theme selector config.""" @@ -1369,7 +1386,7 @@ class ThemeSelector(Selector[ThemeSelectorConfig]): selector_type = "theme" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("include_default", default=False): cv.boolean, } @@ -1385,7 +1402,7 @@ class ThemeSelector(Selector[ThemeSelectorConfig]): return theme -class TimeSelectorConfig(TypedDict): +class TimeSelectorConfig(BaseSelectorConfig): """Class to represent a time selector config.""" @@ -1395,7 +1412,7 @@ class TimeSelector(Selector[TimeSelectorConfig]): selector_type = "time" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: TimeSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -1407,7 +1424,7 @@ class TimeSelector(Selector[TimeSelectorConfig]): return cast(str, data) -class TriggerSelectorConfig(TypedDict): +class TriggerSelectorConfig(BaseSelectorConfig): """Class to represent an trigger selector config.""" @@ -1417,7 +1434,7 @@ class TriggerSelector(Selector[TriggerSelectorConfig]): selector_type = "trigger" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA def __init__(self, config: TriggerSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -1428,7 +1445,7 @@ class TriggerSelector(Selector[TriggerSelectorConfig]): return vol.Schema(cv.TRIGGER_SCHEMA)(data) -class FileSelectorConfig(TypedDict): +class FileSelectorConfig(BaseSelectorConfig): """Class to represent a file selector config.""" accept: str # required @@ -1440,7 +1457,7 @@ class FileSelector(Selector[FileSelectorConfig]): selector_type = "file" - CONFIG_SCHEMA = vol.Schema( + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept vol.Required("accept"): str, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 4873d935537..f157e82bc53 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine, Iterable +from collections.abc import Callable, Coroutine, Iterable import dataclasses from enum import Enum from functools import cache, partial @@ -1094,9 +1094,15 @@ async def _handle_entity_call( async def _async_admin_handler( hass: HomeAssistant, - service_job: HassJob[[ServiceCall], Awaitable[None] | None], + service_job: HassJob[ + [ServiceCall], + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] + | ServiceResponse + | EntityServiceResponse + | None, + ], call: ServiceCall, -) -> None: +) -> ServiceResponse | EntityServiceResponse | None: """Run an admin service.""" if call.context.user_id: user = await hass.auth.async_get_user(call.context.user_id) @@ -1105,9 +1111,10 @@ async def _async_admin_handler( if not user.is_admin: raise Unauthorized(context=call.context) - result = hass.async_run_hass_job(service_job, call) - if result is not None: - await result + task = hass.async_run_hass_job(service_job, call) + if task is not None: + return await task + return None @bind_hass @@ -1116,8 +1123,15 @@ def async_register_admin_service( hass: HomeAssistant, domain: str, service: str, - service_func: Callable[[ServiceCall], Awaitable[None] | None], + service_func: Callable[ + [ServiceCall], + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] + | ServiceResponse + | EntityServiceResponse + | None, + ], schema: VolSchemaType = vol.Schema({}, extra=vol.PREVENT_EXTRA), + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register a service that requires admin access.""" hass.services.async_register( @@ -1129,6 +1143,7 @@ def async_register_admin_service( HassJob(service_func, f"admin service {domain}.{service}"), ), schema, + supports_response, ) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index cb6d8fe81b8..9079d6af300 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2019,6 +2019,34 @@ def add(value, amount, default=_SENTINEL): return default +def apply(value, fn, *args, **kwargs): + """Call the given callable with the provided arguments and keyword arguments.""" + return fn(value, *args, **kwargs) + + +def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: + """Turn a macro with a 'returns' keyword argument into a function that returns what that argument is called with.""" + + def wrapper(value, *args, **kwargs): + return_value = None + + def returns(value): + nonlocal return_value + return_value = value + return value + + # Call the callable with the value and other args + macro(value, *args, **kwargs, returns=returns) + return return_value + + # Remove "macro_" from the macro's name to avoid confusion in the wrapper's name + trimmed_name = macro.name.removeprefix("macro_") + + wrapper.__name__ = trimmed_name + wrapper.__qualname__ = trimmed_name + return wrapper + + def logarithm(value, base=math.e, default=_SENTINEL): """Filter and function to get logarithm of the value with a specific base.""" try: @@ -2572,9 +2600,16 @@ def struct_unpack(value: bytes, format_string: str, offset: int = 0) -> Any | No return None -def base64_encode(value: str) -> str: +def from_hex(value: str) -> bytes: + """Perform hex string decode.""" + return bytes.fromhex(value) + + +def base64_encode(value: str | bytes) -> str: """Perform base64 encode.""" - return base64.b64encode(value.encode("utf-8")).decode("utf-8") + if isinstance(value, str): + value = value.encode("utf-8") + return base64.b64encode(value).decode("utf-8") def base64_decode(value: str, encoding: str | None = "utf-8") -> str | bytes: @@ -3057,9 +3092,11 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): str | jinja2.nodes.Template, CodeType | None ] = weakref.WeakValueDictionary() self.add_extension("jinja2.ext.loopcontrols") + self.add_extension("jinja2.ext.do") self.globals["acos"] = arc_cosine self.globals["as_datetime"] = as_datetime + self.globals["as_function"] = as_function self.globals["as_local"] = dt_util.as_local self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp @@ -3110,7 +3147,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["acos"] = arc_cosine self.filters["add"] = add + self.filters["apply"] = apply self.filters["as_datetime"] = as_datetime + self.filters["as_function"] = as_function self.filters["as_local"] = dt_util.as_local self.filters["as_timedelta"] = as_timedelta self.filters["as_timestamp"] = forgiving_as_timestamp @@ -3131,6 +3170,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["flatten"] = flatten self.filters["float"] = forgiving_float_filter self.filters["from_json"] = from_json + self.filters["from_hex"] = from_hex self.filters["iif"] = iif self.filters["int"] = forgiving_int_filter self.filters["intersect"] = intersect @@ -3169,6 +3209,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["unpack"] = struct_unpack self.filters["version"] = version + self.tests["apply"] = apply self.tests["contains"] = contains self.tests["datetime"] = _is_datetime self.tests["is_number"] = is_number diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 7130264eb0d..bd85391f98f 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -138,6 +138,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): async def _on_hass_stop(_: Event) -> None: """Shutdown coordinator on HomeAssistant stop.""" + # Already cleared on EVENT_HOMEASSISTANT_STOP, via async_fire_internal + self._unsub_shutdown = None await self.async_shutdown() self._unsub_shutdown = self.hass.bus.async_listen_once( diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 11b1233bcda..57a037f0fb7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,12 +1,13 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==1.1.1 +aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 aiodns==3.4.0 +aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 -aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.18 +aiohttp-fast-zlib==0.3.0 +aiohttp==3.12.12 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 @@ -23,23 +24,23 @@ bcrypt==4.2.0 bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 -bluetooth-auto-recovery==1.5.1 +bluetooth-auto-recovery==1.5.2 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 +cryptography==45.0.1 dbus-fast==2.43.0 fnv-hash-fast==1.5.0 -go2rtc-client==0.1.2 +go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==3.48.2 -hass-nabucasa==0.96.0 +habluetooth==3.49.0 +hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250516.0 -home-assistant-intents==2025.5.7 +home-assistant-frontend==20250531.2 +home-assistant-intents==2025.6.10 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 @@ -50,18 +51,18 @@ orjson==3.10.18 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.2.1 -propcache==0.3.1 +propcache==0.3.2 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 PyNaCl==1.5.0 -pyOpenSSL==25.0.0 +pyOpenSSL==25.1.0 pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.7.5 PyYAML==6.0.2 -requests==2.32.3 +requests==2.32.4 securetar==2025.2.1 SQLAlchemy==2.0.40 standard-aifc==3.13.0 @@ -74,7 +75,7 @@ voluptuous-openapi==0.1.0 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.20.0 +yarl==1.20.1 zeroconf==0.147.0 # Constrain pycryptodome to avoid vulnerability @@ -88,9 +89,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.71.0 -grpcio-status==1.71.0 -grpcio-reflection==1.71.0 +grpcio==1.72.1 +grpcio-status==1.72.1 +grpcio-reflection==1.72.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -143,13 +144,9 @@ pubnub!=6.4.0 # https://github.com/dahlia/iso4217/issues/16 iso4217!=1.10.20220401 -# pyOpenSSL 24.0.0 or later required to avoid import errors when -# cryptography 42.0.0 is installed with botocore -pyOpenSSL>=24.0.0 - # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==5.29.2 +protobuf==6.31.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder @@ -205,14 +202,6 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 -# aiofiles keeps getting downgraded by custom components -# causing newer methods to not be available and breaking -# some integrations at startup -# https://github.com/home-assistant/core/issues/127529 -# https://github.com/home-assistant/core/issues/122508 -# https://github.com/home-assistant/core/issues/118004 -aiofiles>=24.1.0 - # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index c2d825a1676..19515fd7945 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -160,7 +160,7 @@ class Throttle: If we cannot acquire the lock, it is running so return None. """ if hasattr(method, "__self__"): - host = getattr(method, "__self__") + host = method.__self__ elif is_func: host = wrapper else: diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index 5571861f417..888da368053 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -28,15 +28,29 @@ class MockStreamReader: return self._content.read(byte_count) +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + class MockPayloadWriter: """Small mock to imitate payload writer.""" def enable_chunking(self) -> None: """Enable chunking.""" + def send_headers(self, *args: Any, **kwargs: Any) -> None: + """Write headers.""" + async def write_headers(self, *args: Any, **kwargs: Any) -> None: """Write headers.""" + async def write(self, *args: Any, **kwargs: Any) -> None: + """Write payload.""" + _MOCK_PAYLOAD_WRITER = MockPayloadWriter() diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index f559512c1a7..d0830d1f8bb 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -7,6 +7,8 @@ from functools import lru_cache from math import floor, log10 from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -24,6 +26,7 @@ from homeassistant.const import ( UnitOfMass, UnitOfPower, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -151,8 +154,8 @@ class BaseUnitConverter: cls, from_unit: str | None, to_unit: str | None ) -> float: """Get floored base10 log ratio between units of measurement.""" - from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) - return floor(max(0, log10(from_ratio / to_ratio))) + ratio = cls.get_unit_ratio(from_unit, to_unit) + return floor(max(0, log10(ratio))) @classmethod @lru_cache @@ -312,6 +315,7 @@ class EnergyDistanceConverter(BaseUnitConverter): UNIT_CLASS = "energy_distance" _UNIT_CONVERSION: dict[str | None, float] = { UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM: 1, + UnitOfEnergyDistance.WATT_HOUR_PER_KM: 10, UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR: 100 * _KM_TO_M / _MILE_TO_M, UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR: 100, } @@ -429,6 +433,17 @@ class PressureConverter(BaseUnitConverter): } +class ReactiveEnergyConverter(BaseUnitConverter): + """Utility to convert reactive energy values.""" + + UNIT_CLASS = "reactive_energy" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR: 1, + UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR: 1 / 1e3, + } + VALID_UNITS = set(UnitOfReactiveEnergy) + + class SpeedConverter(BaseUnitConverter): """Utility to convert speed values.""" @@ -673,6 +688,20 @@ class UnitlessRatioConverter(BaseUnitConverter): } +class MassVolumeConcentrationConverter(BaseUnitConverter): + """Utility to convert mass volume concentration values.""" + + UNIT_CLASS = "concentration" + _UNIT_CONVERSION: dict[str | None, float] = { + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000.0, # 1000 µg/m³ = 1 mg/m³ + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1.0, + } + VALID_UNITS = { + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + } + + class VolumeConverter(BaseUnitConverter): """Utility to convert volume values.""" diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 055f435503f..31f74377a16 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -355,6 +355,7 @@ US_CUSTOMARY_SYSTEM = UnitSystem( ("distance", UnitOfLength.MILLIMETERS): UnitOfLength.INCHES, # Convert non-USCS volumes of gas meters ("gas", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, + ("gas", UnitOfVolume.LITERS): UnitOfVolume.CUBIC_FEET, # Convert non-USCS precipitation ("precipitation", UnitOfLength.CENTIMETERS): UnitOfLength.INCHES, ("precipitation", UnitOfLength.MILLIMETERS): UnitOfLength.INCHES, diff --git a/mypy.ini b/mypy.ini index 94c47d7ce22..1fdab75663e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -405,6 +405,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.alexa_devices.*] +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.alpha_vantage.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2456,6 +2466,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.immich.*] +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.incomfort.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3076,6 +3096,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.miele.*] +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.mikrotik.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3596,6 +3626,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.paperless_ngx.*] +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.peblar.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4086,16 +4126,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.rtsp_to_webrtc.*] -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.russound_rio.*] 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 3e18aacaa93..45a3e41f91a 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -50,6 +50,9 @@ class TypeHintMatch: kwargs_type: str | None = None """kwargs_type is for the special case `**kwargs`""" has_async_counterpart: bool = False + """`function_name` and `async_function_name` share arguments and return type""" + mandatory: bool = False + """bypass ignore_missing_annotations""" def need_to_check_function(self, node: nodes.FunctionDef) -> bool: """Confirm if function should be checked.""" @@ -184,6 +187,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type="bool", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="async_setup_entry", @@ -192,6 +196,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_remove_entry", @@ -200,6 +205,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_unload_entry", @@ -208,6 +214,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_migrate_entry", @@ -216,6 +223,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_remove_config_entry_device", @@ -225,6 +233,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "DeviceEntry", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_reset_platform", @@ -233,6 +242,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type=None, + mandatory=True, ), ], "__any_platform__": [ @@ -246,6 +256,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="async_setup_entry", @@ -255,6 +266,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "AddConfigEntryEntitiesCallback", }, return_type=None, + mandatory=True, ), ], "application_credentials": [ @@ -266,6 +278,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "ClientCredential", }, return_type="AbstractOAuth2Implementation", + mandatory=True, ), TypeHintMatch( function_name="async_get_authorization_server", @@ -273,6 +286,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 0: "HomeAssistant", }, return_type="AuthorizationServer", + mandatory=True, ), ], "backup": [ @@ -282,6 +296,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 0: "HomeAssistant", }, return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_post_backup", @@ -289,6 +304,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 0: "HomeAssistant", }, return_type=None, + mandatory=True, ), ], "cast": [ @@ -299,6 +315,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type="list[BrowseMedia]", + mandatory=True, ), TypeHintMatch( function_name="async_browse_media", @@ -309,6 +326,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "str", }, return_type=["BrowseMedia", "BrowseMedia | None"], + mandatory=True, ), TypeHintMatch( function_name="async_play_media", @@ -320,6 +338,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 4: "str", }, return_type="bool", + mandatory=True, ), ], "config_flow": [ @@ -329,6 +348,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 0: "HomeAssistant", }, return_type="bool", + mandatory=True, ), ], "device_action": [ @@ -339,6 +359,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="ConfigType", + mandatory=True, ), TypeHintMatch( function_name="async_call_action_from_config", @@ -349,6 +370,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "Context | None", }, return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_get_action_capabilities", @@ -357,6 +379,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="dict[str, Schema]", + mandatory=True, ), TypeHintMatch( function_name="async_get_actions", @@ -365,6 +388,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + mandatory=True, ), ], "device_condition": [ @@ -375,6 +399,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="ConfigType", + mandatory=True, ), TypeHintMatch( function_name="async_condition_from_config", @@ -383,6 +408,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="ConditionCheckerType", + mandatory=True, ), TypeHintMatch( function_name="async_get_condition_capabilities", @@ -391,6 +417,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="dict[str, Schema]", + mandatory=True, ), TypeHintMatch( function_name="async_get_conditions", @@ -399,6 +426,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + mandatory=True, ), ], "device_tracker": [ @@ -411,6 +439,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "DiscoveryInfoType | None", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_setup_scanner", @@ -421,6 +450,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "DiscoveryInfoType | None", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="get_scanner", @@ -430,6 +460,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type=["DeviceScanner", None], has_async_counterpart=True, + mandatory=True, ), ], "device_trigger": [ @@ -440,6 +471,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="ConfigType", + mandatory=True, ), TypeHintMatch( function_name="async_attach_trigger", @@ -450,6 +482,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "TriggerInfo", }, return_type="CALLBACK_TYPE", + mandatory=True, ), TypeHintMatch( function_name="async_get_trigger_capabilities", @@ -458,6 +491,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="dict[str, Schema]", + mandatory=True, ), TypeHintMatch( function_name="async_get_triggers", @@ -466,6 +500,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + mandatory=True, ), ], "diagnostics": [ @@ -476,6 +511,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type="Mapping[str, Any]", + mandatory=True, ), TypeHintMatch( function_name="async_get_device_diagnostics", @@ -485,6 +521,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "DeviceEntry", }, return_type="Mapping[str, Any]", + mandatory=True, ), ], "notify": [ @@ -497,6 +534,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type=["BaseNotificationService", None], has_async_counterpart=True, + mandatory=True, ), ], } @@ -511,6 +549,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="async_step_*", arg_types={}, return_type="FlowResult", + mandatory=True, ), ], ), @@ -523,6 +562,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 0: "ConfigEntry", }, return_type="OptionsFlow", + mandatory=True, ), TypeHintMatch( function_name="async_step_dhcp", @@ -530,6 +570,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "DhcpServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_hassio", @@ -537,6 +578,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "HassioServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_homekit", @@ -544,6 +586,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "ZeroconfServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_mqtt", @@ -551,6 +594,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "MqttServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_reauth", @@ -558,6 +602,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "Mapping[str, Any]", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_ssdp", @@ -565,6 +610,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "SsdpServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_usb", @@ -572,6 +618,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "UsbServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_zeroconf", @@ -579,11 +626,13 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "ZeroconfServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_*", arg_types={}, return_type="ConfigFlowResult", + mandatory=True, ), ], ), @@ -594,6 +643,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="async_step_*", arg_types={}, return_type="ConfigFlowResult", + mandatory=True, ), ], ), @@ -604,6 +654,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="async_step_*", arg_types={}, return_type="SubentryFlowResult", + mandatory=True, ), ], ), @@ -616,6 +667,7 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="should_poll", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="unique_id", @@ -664,14 +716,17 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="available", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="assumed_state", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="force_update", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="supported_features", @@ -680,10 +735,12 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="entity_registry_enabled_default", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="entity_registry_visible_default", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="attribution", @@ -696,23 +753,28 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="async_removed_from_registry", return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_added_to_hass", return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_will_remove_from_hass", return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_registry_entry_updated", return_type=None, + mandatory=True, ), TypeHintMatch( function_name="update", return_type=None, has_async_counterpart=True, + mandatory=True, ), ] _RESTORE_ENTITY_MATCH: list[TypeHintMatch] = [ @@ -739,18 +801,21 @@ _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_off", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="toggle", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), ] _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { @@ -778,10 +843,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="code_arm_required", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="AlarmControlPanelEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="alarm_disarm", @@ -790,6 +857,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="alarm_arm_home", @@ -798,6 +866,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="alarm_arm_away", @@ -806,6 +875,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="alarm_arm_night", @@ -814,6 +884,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="alarm_arm_vacation", @@ -822,6 +893,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="alarm_trigger", @@ -830,6 +902,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="alarm_arm_custom_bypass", @@ -838,6 +911,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), @@ -879,12 +953,13 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { matches=[ TypeHintMatch( function_name="device_class", - return_type=["ButtonDeviceClass", "str", None], + return_type=["ButtonDeviceClass", None], ), TypeHintMatch( function_name="press", return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), @@ -913,6 +988,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { 3: "datetime", }, return_type="list[CalendarEvent]", + mandatory=True, ), ], ), @@ -932,18 +1008,22 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="entity_picture", return_type="str", + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="CameraEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="is_recording", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="is_streaming", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="brand", @@ -952,6 +1032,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="motion_detection_enabled", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="model", @@ -960,6 +1041,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="frame_interval", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="frontend_stream_type", @@ -968,6 +1050,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="available", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_create_stream", @@ -1000,6 +1083,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { 2: "float", }, return_type="StreamResponse", + mandatory=True, ), TypeHintMatch( function_name="handle_async_mjpeg_stream", @@ -1011,26 +1095,31 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="is_on", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="turn_off", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_on", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="enable_motion_detection", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="disable_motion_detection", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="async_handle_async_webrtc_offer", @@ -1040,6 +1129,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { 3: "WebRTCSendMessage", }, return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_on_webrtc_candidate", @@ -1048,6 +1138,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { 2: "RTCIceCandidateInit", }, return_type=None, + mandatory=True, ), TypeHintMatch( function_name="close_webrtc_session", @@ -1055,10 +1146,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "str", }, return_type=None, + mandatory=True, ), TypeHintMatch( function_name="_async_get_webrtc_client_configuration", return_type="WebRTCClientConfiguration", + mandatory=True, ), ], ), @@ -1078,10 +1171,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="precision", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="temperature_unit", return_type="str", + mandatory=True, ), TypeHintMatch( function_name="current_humidity", @@ -1098,6 +1193,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="hvac_modes", return_type="list[HVACMode]", + mandatory=True, ), TypeHintMatch( function_name="hvac_action", @@ -1156,6 +1252,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { kwargs_type="Any", return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_humidity", @@ -1164,6 +1261,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_fan_mode", @@ -1172,6 +1270,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_hvac_mode", @@ -1180,6 +1279,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_swing_mode", @@ -1188,6 +1288,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_preset_mode", @@ -1196,46 +1297,56 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_aux_heat_on", return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_aux_heat_off", return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_on", return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_off", return_type="None", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="ClimateEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="min_temp", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="max_temp", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="min_humidity", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="max_humidity", return_type="float", + mandatory=True, ), ], ), @@ -1279,66 +1390,77 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="supported_features", return_type="CoverEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="open_cover", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="close_cover", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="toggle", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_cover_position", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="stop_cover", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="open_cover_tilt", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="close_cover_tilt", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_cover_tilt_position", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="stop_cover_tilt", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="toggle_tilt", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), @@ -1362,6 +1484,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="source_type", return_type="SourceType", + mandatory=True, ), ], ), @@ -1371,10 +1494,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="force_update", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="location_accuracy", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="location_name", @@ -1412,10 +1537,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="state", return_type="str", + mandatory=True, ), TypeHintMatch( function_name="is_connected", return_type="bool", + mandatory=True, ), ], ), @@ -1453,10 +1580,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="speed_count", return_type="int", + mandatory=True, ), TypeHintMatch( function_name="percentage_step", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="current_direction", @@ -1477,24 +1606,28 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="supported_features", return_type="FanEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="set_percentage", arg_types={1: "int"}, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_preset_mode", arg_types={1: "str"}, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_direction", arg_types={1: "str"}, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_on", @@ -1505,12 +1638,14 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="oscillate", arg_types={1: "bool"}, return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), @@ -1530,6 +1665,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="source", return_type="str", + mandatory=True, ), TypeHintMatch( function_name="distance", @@ -1561,20 +1697,24 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="camera_entity", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="confidence", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="device_class", return_type=["ImageProcessingDeviceClass", None], + mandatory=True, ), TypeHintMatch( function_name="process_image", arg_types={1: "bytes"}, return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), @@ -1589,6 +1729,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), @@ -1612,42 +1753,51 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="available_modes", return_type=["list[str]", None], + mandatory=True, ), TypeHintMatch( function_name="device_class", return_type=["HumidifierDeviceClass", None], + mandatory=True, ), TypeHintMatch( function_name="min_humidity", return_type=["float"], + mandatory=True, ), TypeHintMatch( function_name="max_humidity", return_type=["float"], + mandatory=True, ), TypeHintMatch( function_name="mode", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="HumidifierEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="target_humidity", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="set_humidity", arg_types={1: "int"}, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_mode", arg_types={1: "str"}, return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), @@ -1675,6 +1825,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="color_mode", return_type=["ColorMode", "str", None], + mandatory=True, ), TypeHintMatch( function_name="hs_color", @@ -1687,26 +1838,32 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="rgb_color", return_type=["tuple[int, int, int]", None], + mandatory=True, ), TypeHintMatch( function_name="rgbw_color", return_type=["tuple[int, int, int, int]", None], + mandatory=True, ), TypeHintMatch( function_name="rgbww_color", return_type=["tuple[int, int, int, int, int]", None], + mandatory=True, ), TypeHintMatch( function_name="color_temp", return_type=["int", None], + mandatory=True, ), TypeHintMatch( function_name="min_mireds", return_type="int", + mandatory=True, ), TypeHintMatch( function_name="max_mireds", return_type="int", + mandatory=True, ), TypeHintMatch( function_name="effect_list", @@ -1723,10 +1880,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="supported_color_modes", return_type=["set[ColorMode]", "set[str]", None], + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="LightEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="turn_on", @@ -1751,6 +1910,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), @@ -3195,8 +3355,11 @@ class HassTypeHintChecker(BaseChecker): self._class_matchers.reverse() - def _ignore_function( - self, node: nodes.FunctionDef, annotations: list[nodes.NodeNG | None] + def _ignore_function_match( + self, + node: nodes.FunctionDef, + annotations: list[nodes.NodeNG | None], + match: TypeHintMatch, ) -> bool: """Check if we can skip the function validation.""" return ( @@ -3204,6 +3367,8 @@ class HassTypeHintChecker(BaseChecker): not self._in_test_module # some modules have checks forced and self._module_platform not in _FORCE_ANNOTATION_PLATFORMS + # some matches have checks forced + and not match.mandatory # other modules are only checked ignore_missing_annotations and self.linter.config.ignore_missing_annotations and node.returns is None @@ -3246,7 +3411,7 @@ class HassTypeHintChecker(BaseChecker): continue annotations = _get_all_annotations(function_node) - if self._ignore_function(function_node, annotations): + if self._ignore_function_match(function_node, annotations, match): continue self._check_function(function_node, match, annotations) @@ -3255,8 +3420,6 @@ class HassTypeHintChecker(BaseChecker): def visit_functiondef(self, node: nodes.FunctionDef) -> None: """Apply relevant type hint checks on a FunctionDef node.""" annotations = _get_all_annotations(node) - if self._ignore_function(node, annotations): - return # Check method or function matchers. if node.is_method(): @@ -3277,14 +3440,15 @@ class HassTypeHintChecker(BaseChecker): matchers = self._function_matchers # Check that common arguments are correctly typed. - for arg_name, expected_type in _COMMON_ARGUMENTS.items(): - arg_node, annotation = _get_named_annotation(node, arg_name) - if arg_node and not _is_valid_type(expected_type, annotation): - self.add_message( - "hass-argument-type", - node=arg_node, - args=(arg_name, expected_type, node.name), - ) + if not self.linter.config.ignore_missing_annotations: + for arg_name, expected_type in _COMMON_ARGUMENTS.items(): + arg_node, annotation = _get_named_annotation(node, arg_name) + if arg_node and not _is_valid_type(expected_type, annotation): + self.add_message( + "hass-argument-type", + node=arg_node, + args=(arg_name, expected_type, node.name), + ) for match in matchers: if not match.need_to_check_function(node): @@ -3299,6 +3463,8 @@ class HassTypeHintChecker(BaseChecker): match: TypeHintMatch, annotations: list[nodes.NodeNG | None], ) -> None: + if self._ignore_function_match(node, annotations, match): + return # Check that all positional arguments are correctly annotated. if match.arg_types: for key, expected_type in match.arg_types.items(): diff --git a/pyproject.toml b/pyproject.toml index 2cb43bc3e49..07f19628d0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.5.3" +version = "2025.6.0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." @@ -24,13 +24,14 @@ classifiers = [ requires-python = ">=3.13.2" dependencies = [ "aiodns==3.4.0", + "aiofiles==24.1.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==3.12.12", "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.3", + "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", "annotatedyaml==0.4.5", @@ -52,7 +53,7 @@ dependencies = [ "ha-ffmpeg==3.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.96.0", + "hass-nabucasa==0.101.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 @@ -66,7 +67,7 @@ dependencies = [ # 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", + "home-assistant-intents==2025.6.10", "ifaddr==0.2.0", "Jinja2==3.1.6", "lru-dict==1.3.0", @@ -82,10 +83,10 @@ dependencies = [ "numpy==2.2.2", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.1", + "cryptography==45.0.1", "Pillow==11.2.1", - "propcache==0.3.1", - "pyOpenSSL==25.0.0", + "propcache==0.3.2", + "pyOpenSSL==25.1.0", "orjson==3.10.18", "packaging>=23.1", "psutil-home-assistant==0.0.1", @@ -106,7 +107,7 @@ dependencies = [ # dependencies to stage 0. "PyTurboJPEG==1.7.5", "PyYAML==6.0.2", - "requests==2.32.3", + "requests==2.32.4", "securetar==2025.2.1", "SQLAlchemy==2.0.40", "standard-aifc==3.13.0", @@ -121,7 +122,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.1.0", - "yarl==1.20.0", + "yarl==1.20.1", "webrtc-models==0.3.0", "zeroconf==0.147.0", ] @@ -604,8 +605,6 @@ filterwarnings = [ # - 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 @@ -707,6 +706,7 @@ select = [ "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 + "B009", # Do not call getattr with a constant attribute value. It is not any safer than normal property access. "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 diff --git a/requirements.txt b/requirements.txt index 27095417cb0..6dc604d877b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,10 +4,11 @@ # Home Assistant Core aiodns==3.4.0 +aiofiles==24.1.0 aiohasupervisor==0.3.1 -aiohttp==3.11.18 +aiohttp==3.12.12 aiohttp_cors==0.7.0 -aiohttp-fast-zlib==0.2.3 +aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 annotatedyaml==0.4.5 @@ -23,21 +24,21 @@ ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 ha-ffmpeg==3.2.2 -hass-nabucasa==0.96.0 +hass-nabucasa==0.101.0 hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 -home-assistant-intents==2025.5.7 +home-assistant-intents==2025.6.10 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 numpy==2.2.2 PyJWT==2.10.1 -cryptography==44.0.1 +cryptography==45.0.1 Pillow==11.2.1 -propcache==0.3.1 -pyOpenSSL==25.0.0 +propcache==0.3.2 +pyOpenSSL==25.1.0 orjson==3.10.18 packaging>=23.1 psutil-home-assistant==0.0.1 @@ -46,7 +47,7 @@ pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.7.5 PyYAML==6.0.2 -requests==2.32.3 +requests==2.32.4 securetar==2025.2.1 SQLAlchemy==2.0.40 standard-aifc==3.13.0 @@ -58,6 +59,6 @@ uv==0.7.1 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.1.0 -yarl==1.20.0 +yarl==1.20.1 webrtc-models==0.3.0 zeroconf==0.147.0 diff --git a/requirements_all.txt b/requirements_all.txt index f3451ca4a8b..b70c806c1be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -54,7 +54,7 @@ PyFlick==1.1.3 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.7 +PyFronius==0.8.0 # homeassistant.components.pyload PyLoadAPI==1.4.2 @@ -69,9 +69,6 @@ PyMetno==0.13.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.17 -# homeassistant.components.nina -PyNINA==0.3.5 - # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.5.0 @@ -84,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.60.1 +PySwitchbot==0.64.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -184,6 +181,9 @@ aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 +# homeassistant.components.alexa_devices +aioamazondevices==3.0.6 + # homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.08.0 @@ -217,7 +217,7 @@ aiobotocore==2.21.1 aiocomelit==0.12.3 # homeassistant.components.dhcp -aiodhcpwatcher==1.1.1 +aiodhcpwatcher==1.2.0 # homeassistant.components.dhcp aiodiscover==2.7.0 @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==30.1.0 +aioesphomeapi==32.2.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -265,7 +265,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.0 +aiohomeconnect==0.17.1 # homeassistant.components.homekit_controller aiohomekit==3.2.14 @@ -279,6 +279,9 @@ aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 +# homeassistant.components.immich +aioimmich==0.9.1 + # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -286,7 +289,7 @@ aiokafka==0.10.0 aiokef==0.2.16 # homeassistant.components.rehlko -aiokem==0.5.10 +aiokem==0.5.12 # homeassistant.components.lifx aiolifx-effects==0.3.2 @@ -295,7 +298,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.4 +aiolifx==1.1.5 # homeassistant.components.lookin aiolookin==1.0.0 @@ -319,7 +322,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.2 +aiontfy==0.5.3 # homeassistant.components.nut aionut==4.3.4 @@ -402,7 +405,7 @@ aiosyncthing==0.5.1 aiotankerkoenig==0.4.2 # homeassistant.components.tedee -aiotedee==0.2.20 +aiotedee==0.2.23 # homeassistant.components.tractive aiotractive==0.6.0 @@ -468,7 +471,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.1 +androidtvremote2==0.2.2 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 @@ -480,7 +483,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.47.2 +anthropic==0.52.0 # homeassistant.components.mcp_server anyio==4.9.0 @@ -495,7 +498,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.6.0 +apsystems-ez1==2.7.0 # homeassistant.components.aqualogic aqualogic==2.6 @@ -545,7 +548,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.0 +automower-ble==0.2.1 # homeassistant.components.generic # homeassistant.components.stream @@ -607,7 +610,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.15.1 +bleak-esphome==2.16.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 @@ -637,7 +640,7 @@ bluemaestro-ble==0.4.1 bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.5.1 +bluetooth-auto-recovery==1.5.2 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -695,7 +698,7 @@ buienradar==1.0.6 cached-ipaddress==0.10.0 # homeassistant.components.caldav -caldav==1.3.9 +caldav==1.6.0 # homeassistant.components.cisco_mobility_express ciscomobilityexpress==0.3.9 @@ -747,13 +750,13 @@ crownstone-uart==2.1.0 datadog==0.15.0 # homeassistant.components.metoffice -datapoint==0.9.9 +datapoint==0.12.1 # homeassistant.components.bluetooth dbus-fast==2.43.0 # homeassistant.components.debugpy -debugpy==1.8.13 +debugpy==1.8.14 # homeassistant.components.decora_wifi # decora-wifi==1.4 @@ -762,7 +765,7 @@ debugpy==1.8.13 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.2.1 +deebot-client==13.3.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -776,7 +779,7 @@ deluge-client==1.10.2 demetriek==1.2.0 # homeassistant.components.denonavr -denonavr==1.0.1 +denonavr==1.1.1 # homeassistant.components.devialet devialet==1.5.7 @@ -833,7 +836,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.1.0 +eheimdigital==1.2.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -875,7 +878,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.10.2 +env-canada==0.11.2 # homeassistant.components.season ephem==4.1.6 @@ -986,7 +989,7 @@ gardena-bluetooth==1.6.0 gassist-text==0.0.12 # homeassistant.components.google -gcal-sync==7.0.1 +gcal-sync==7.1.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -1023,7 +1026,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.2 +go2rtc-client==0.2.1 # homeassistant.components.goalzero goalzero==0.2.2 @@ -1048,7 +1051,7 @@ google-cloud-texttospeech==2.25.1 google-genai==1.7.0 # homeassistant.components.google_travel_time -google-maps-routing==0.6.14 +google-maps-routing==0.6.15 # homeassistant.components.nest google-nest-sdm==7.1.4 @@ -1115,13 +1118,13 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.3.7 +habiticalib==0.4.0 # homeassistant.components.bluetooth -habluetooth==3.48.2 +habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.96.0 +hass-nabucasa==0.101.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1130,7 +1133,7 @@ hass-splunk==0.1.1 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.0 +hdate[astral]==1.1.1 # homeassistant.components.heatmiser heatmiserV3==2.0.3 @@ -1158,16 +1161,16 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.73 +holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250516.0 +home-assistant-frontend==20250531.2 # homeassistant.components.conversation -home-assistant-intents==2025.5.7 +home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.1.1 +homematicip==2.0.4 # homeassistant.components.horizon horimote==0.4.1 @@ -1200,7 +1203,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.2.4 +ical==10.0.0 # homeassistant.components.caldav icalendar==6.1.0 @@ -1218,7 +1221,7 @@ ifaddr==0.2.0 iglo==1.2.7 # homeassistant.components.igloohome -igloohome-api==0.1.0 +igloohome-api==0.1.1 # homeassistant.components.ihc ihcsdk==2.8.5 @@ -1230,16 +1233,16 @@ imeon_inverter_api==0.3.12 imgw_pib==1.0.10 # homeassistant.components.incomfort -incomfort-client==0.6.8 +incomfort-client==0.6.9 # homeassistant.components.influxdb -influxdb-client==1.24.0 +influxdb-client==1.48.0 # homeassistant.components.influxdb influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.16.1 +inkbird-ble==0.16.2 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1294,7 +1297,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.3.8.214559 +knx-frontend==2025.4.1.91934 # homeassistant.components.konnected konnected==1.2.0 @@ -1312,7 +1315,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.4 +lcn-frontend==0.2.5 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1324,7 +1327,7 @@ leaone-ble==0.3.0 led-ble==1.1.7 # homeassistant.components.lektrico -lektricowifi==0.0.43 +lektricowifi==0.1 # homeassistant.components.letpot letpot==0.4.0 @@ -1394,7 +1397,7 @@ mbddns==0.1.2 mcp==1.5.0 # homeassistant.components.minecraft_server -mcstatus==11.1.1 +mcstatus==12.0.1 # homeassistant.components.meater meater-python==0.0.8 @@ -1445,7 +1448,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.26 +motionblinds==0.6.27 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1496,7 +1499,7 @@ nettigo-air-monitor==4.1.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.7.0 +nexia==2.10.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1508,7 +1511,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.4.10 +nhc==0.4.12 # homeassistant.components.nibe_heatpump nibe==2.17.0 @@ -1614,7 +1617,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.12.1 +opower==0.12.3 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1682,7 +1685,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.3 +plugwise==1.7.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1759,7 +1762,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.1.2 +py-nextbusnext==2.2.0 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1768,10 +1771,10 @@ py-nightscout==1.2.2 py-schluter==0.1.7 # homeassistant.components.ecovacs -py-sucks==0.9.10 +py-sucks==0.9.11 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.2 +py-synologydsm-api==2.7.3 # homeassistant.components.atome pyAtome==0.1.1 @@ -1829,7 +1832,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.9.0 +pyaprilaire==0.9.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 @@ -1838,7 +1841,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==9.0.0 +pyatmo==9.2.0 # homeassistant.components.apple_tv pyatv==0.16.0 @@ -1922,7 +1925,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.3.0 +pydrawise==2025.6.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 @@ -1970,7 +1973,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.1.2 +pyezvizapi==1.0.0.7 # homeassistant.components.fibaro pyfibaro==0.8.3 @@ -2048,7 +2051,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.15 +pyiskra==0.1.21 # homeassistant.components.iss pyiss==1.0.1 @@ -2093,7 +2096,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.4 +pylamarzocco==2.0.8 # homeassistant.components.lastfm pylast==5.1.0 @@ -2135,7 +2138,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.4.3 +pymiele==0.5.2 # homeassistant.components.xiaomi_tv pymitv==1.4.3 @@ -2164,11 +2167,14 @@ pynetgear==0.10.10 # homeassistant.components.netio pynetio==0.1.9.1 +# homeassistant.components.nina +pynina==0.3.6 + # homeassistant.components.nobo_hub pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.4 +pynordpool==0.3.0 # homeassistant.components.nuki pynuki==1.6.3 @@ -2223,11 +2229,14 @@ pyownet==0.10.0.post1 # homeassistant.components.palazzetti pypalazzetti==0.1.19 +# homeassistant.components.paperless_ngx +pypaperless==4.1.0 + # homeassistant.components.elv pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.5 +pypck==0.8.6 # homeassistant.components.pglab pypglab==0.0.5 @@ -2241,6 +2250,9 @@ pyplaato==0.0.19 # homeassistant.components.point pypoint==3.0.0 +# homeassistant.components.probe_plus +pyprobeplus==1.0.1 + # homeassistant.components.profiler pyprof2calltree==1.4.5 @@ -2325,8 +2337,11 @@ pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 +# homeassistant.components.smarla +pysmarlaapi==0.8.2 + # homeassistant.components.smartthings -pysmartthings==3.2.3 +pysmartthings==3.2.4 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -2338,7 +2353,7 @@ pysmhi==1.0.2 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.4 +pysmlight==0.2.5 # homeassistant.components.snmp pysnmp==6.2.6 @@ -2362,7 +2377,7 @@ pysqueezebox==0.12.1 pystiebeleltron==0.1.0 # homeassistant.components.suez_water -pysuezV2==2.0.4 +pysuezV2==2.0.5 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -2437,7 +2452,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.5 +python-linkplay==0.2.11 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -2471,7 +2486,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.4 +python-picnic-api2==1.3.1 # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2492,7 +2507,7 @@ python-snoo==0.6.6 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.14 +python-tado==0.18.15 # homeassistant.components.technove python-technove==2.0.0 @@ -2637,7 +2652,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.3 +reolink-aio==0.13.5 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2675,9 +2690,6 @@ rova==0.4.1 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 -# homeassistant.components.rtsp_to_webrtc -rtsp-to-webrtc==0.5.1 - # homeassistant.components.russound_rnet russound==0.2.0 @@ -2719,7 +2731,7 @@ sense-energy==0.13.8 sensirion-ble==0.1.1 # homeassistant.components.sensorpro -sensorpro-ble==0.7.0 +sensorpro-ble==0.7.1 # homeassistant.components.sensorpush_cloud sensorpush-api==2.1.2 @@ -2818,7 +2830,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.2.2 +starlink-grpc-core==1.2.3 # homeassistant.components.statsd statsd==3.2.1 @@ -2847,7 +2859,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.3.1 +switchbot-api==2.4.0 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -2888,7 +2900,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.0.17 +tesla-fleet-api==1.1.1 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2897,7 +2909,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.7.7 +teslemetry-stream==0.7.9 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2975,7 +2987,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.6.0 +uiprotect==7.11.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -3094,6 +3106,9 @@ wled==0.21.0 # homeassistant.components.wolflink wolf-comm==0.0.23 +# homeassistant.components.wsdot +wsdot==0.0.1 + # homeassistant.components.wyoming wyoming==1.5.4 @@ -3101,10 +3116,10 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.38.0 +xiaomi-ble==0.39.0 # homeassistant.components.knx -xknx==3.6.0 +xknx==3.8.0 # homeassistant.components.knx xknxproject==3.8.2 @@ -3147,7 +3162,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.05.22 +yt-dlp[default]==2025.06.09 # homeassistant.components.zabbix zabbix-utils==2.0.2 @@ -3155,6 +3170,9 @@ zabbix-utils==2.0.2 # homeassistant.components.zamg zamg==0.3.6 +# homeassistant.components.zimi +zcc-helper==3.5.2 + # homeassistant.components.zeroconf zeroconf==0.147.0 @@ -3162,7 +3180,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.57 +zha==0.0.59 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index 80be991cfcd..e97b71fa7dc 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,18 +7,18 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.9 +astroid==3.3.10 coverage==7.6.12 freezegun==1.5.1 -go2rtc-client==0.1.2 +go2rtc-client==0.2.1 license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a8 pre-commit==4.0.0 pydantic==2.11.3 -pylint==3.3.6 +pylint==3.3.7 pylint-per-file-ignores==1.4.0 -pipdeptree==2.25.1 +pipdeptree==2.26.1 pytest-asyncio==0.26.0 pytest-aiohttp==1.1.0 pytest-cov==6.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 662353e1e57..39fe3779466 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -51,7 +51,7 @@ PyFlick==1.1.3 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.7 +PyFronius==0.8.0 # homeassistant.components.pyload PyLoadAPI==1.4.2 @@ -66,9 +66,6 @@ PyMetno==0.13.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.17 -# homeassistant.components.nina -PyNINA==0.3.5 - # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.5.0 @@ -81,7 +78,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.60.1 +PySwitchbot==0.64.1 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -172,6 +169,9 @@ aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 +# homeassistant.components.alexa_devices +aioamazondevices==3.0.6 + # homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.08.0 @@ -205,7 +205,7 @@ aiobotocore==2.21.1 aiocomelit==0.12.3 # homeassistant.components.dhcp -aiodhcpwatcher==1.1.1 +aiodhcpwatcher==1.2.0 # homeassistant.components.dhcp aiodiscover==2.7.0 @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==30.1.0 +aioesphomeapi==32.2.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -250,7 +250,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.0 +aiohomeconnect==0.17.1 # homeassistant.components.homekit_controller aiohomekit==3.2.14 @@ -264,11 +264,14 @@ aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 +# homeassistant.components.immich +aioimmich==0.9.1 + # homeassistant.components.apache_kafka aiokafka==0.10.0 # homeassistant.components.rehlko -aiokem==0.5.10 +aiokem==0.5.12 # homeassistant.components.lifx aiolifx-effects==0.3.2 @@ -277,7 +280,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.4 +aiolifx==1.1.5 # homeassistant.components.lookin aiolookin==1.0.0 @@ -301,7 +304,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.2 +aiontfy==0.5.3 # homeassistant.components.nut aionut==4.3.4 @@ -384,7 +387,7 @@ aiosyncthing==0.5.1 aiotankerkoenig==0.4.2 # homeassistant.components.tedee -aiotedee==0.2.20 +aiotedee==0.2.23 # homeassistant.components.tractive aiotractive==0.6.0 @@ -444,7 +447,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.1 +androidtvremote2==0.2.2 # homeassistant.components.anova anova-wifi==0.17.0 @@ -453,7 +456,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.47.2 +anthropic==0.52.0 # homeassistant.components.mcp_server anyio==4.9.0 @@ -468,7 +471,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.6.0 +apsystems-ez1==2.7.0 # homeassistant.components.aranet aranet4==2.5.1 @@ -500,7 +503,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.0 +automower-ble==0.2.1 # homeassistant.components.generic # homeassistant.components.stream @@ -530,6 +533,9 @@ babel==2.15.0 # homeassistant.components.homekit base36==0.1.1 +# homeassistant.components.eddystone_temperature +# beacontools[scan]==2.1.0 + # homeassistant.components.scrape beautifulsoup4==4.13.3 @@ -538,7 +544,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.15.1 +bleak-esphome==2.16.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 @@ -558,11 +564,14 @@ bluecurrent-api==1.2.3 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 +# homeassistant.components.decora +# bluepy==1.3.0 + # homeassistant.components.bluetooth bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.5.1 +bluetooth-auto-recovery==1.5.2 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -607,7 +616,7 @@ buienradar==1.0.6 cached-ipaddress==0.10.0 # homeassistant.components.caldav -caldav==1.3.9 +caldav==1.6.0 # homeassistant.components.coinbase coinbase-advanced-py==1.2.2 @@ -644,16 +653,19 @@ crownstone-uart==2.1.0 datadog==0.15.0 # homeassistant.components.metoffice -datapoint==0.9.9 +datapoint==0.12.1 # homeassistant.components.bluetooth dbus-fast==2.43.0 # homeassistant.components.debugpy -debugpy==1.8.13 +debugpy==1.8.14 + +# homeassistant.components.decora +# decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.2.1 +deebot-client==13.3.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -667,7 +679,7 @@ deluge-client==1.10.2 demetriek==1.2.0 # homeassistant.components.denonavr -denonavr==1.0.1 +denonavr==1.1.1 # homeassistant.components.devialet devialet==1.5.7 @@ -712,7 +724,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.1.0 +eheimdigital==1.2.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -745,7 +757,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.10.2 +env-canada==0.11.2 # homeassistant.components.season ephem==4.1.6 @@ -780,6 +792,10 @@ evolutionhttp==0.0.18 # homeassistant.components.faa_delays faadelays==2023.9.1 +# homeassistant.components.dlib_face_detect +# homeassistant.components.dlib_face_identify +# face-recognition==1.2.3 + # homeassistant.components.fastdotcom fastdotcom==0.0.3 @@ -840,7 +856,7 @@ gardena-bluetooth==1.6.0 gassist-text==0.0.12 # homeassistant.components.google -gcal-sync==7.0.1 +gcal-sync==7.1.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -874,7 +890,7 @@ gios==6.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.2 +go2rtc-client==0.2.1 # homeassistant.components.goalzero goalzero==0.2.2 @@ -899,7 +915,7 @@ google-cloud-texttospeech==2.25.1 google-genai==1.7.0 # homeassistant.components.google_travel_time -google-maps-routing==0.6.14 +google-maps-routing==0.6.15 # homeassistant.components.nest google-nest-sdm==7.1.4 @@ -938,6 +954,9 @@ growattServer==1.6.0 # homeassistant.components.google_sheets gspread==5.5.0 +# homeassistant.components.gstreamer +gstreamer-player==1.1.2 + # homeassistant.components.profiler guppy3==3.1.5 @@ -957,19 +976,19 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.3.7 +habiticalib==0.4.0 # homeassistant.components.bluetooth -habluetooth==3.48.2 +habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.96.0 +hass-nabucasa==0.101.0 # homeassistant.components.conversation hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.0 +hdate[astral]==1.1.1 # homeassistant.components.here_travel_time here-routing==1.0.1 @@ -988,16 +1007,16 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.73 +holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250516.0 +home-assistant-frontend==20250531.2 # homeassistant.components.conversation -home-assistant-intents==2025.5.7 +home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.1.1 +homematicip==2.0.4 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -1021,7 +1040,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.2.4 +ical==10.0.0 # homeassistant.components.caldav icalendar==6.1.0 @@ -1036,7 +1055,7 @@ idasen-ha==2.6.3 ifaddr==0.2.0 # homeassistant.components.igloohome -igloohome-api==0.1.0 +igloohome-api==0.1.1 # homeassistant.components.imeon_inverter imeon_inverter_api==0.3.12 @@ -1045,16 +1064,16 @@ imeon_inverter_api==0.3.12 imgw_pib==1.0.10 # homeassistant.components.incomfort -incomfort-client==0.6.8 +incomfort-client==0.6.9 # homeassistant.components.influxdb -influxdb-client==1.24.0 +influxdb-client==1.48.0 # homeassistant.components.influxdb influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.16.1 +inkbird-ble==0.16.2 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1097,7 +1116,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.3.8.214559 +knx-frontend==2025.4.1.91934 # homeassistant.components.konnected konnected==1.2.0 @@ -1112,7 +1131,7 @@ lacrosse-view==1.1.1 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.4 +lcn-frontend==0.2.5 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1124,7 +1143,7 @@ leaone-ble==0.3.0 led-ble==1.1.7 # homeassistant.components.lektrico -lektricowifi==0.0.43 +lektricowifi==0.1 # homeassistant.components.letpot letpot==0.4.0 @@ -1173,7 +1192,7 @@ mbddns==0.1.2 mcp==1.5.0 # homeassistant.components.minecraft_server -mcstatus==11.1.1 +mcstatus==12.0.1 # homeassistant.components.meater meater-python==0.0.8 @@ -1218,7 +1237,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.26 +motionblinds==0.6.27 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1260,7 +1279,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.1.0 # homeassistant.components.nexia -nexia==2.7.0 +nexia==2.10.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1272,7 +1291,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.4.10 +nhc==0.4.12 # homeassistant.components.nibe_heatpump nibe==2.17.0 @@ -1351,7 +1370,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.12.1 +opower==0.12.3 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1383,6 +1402,11 @@ peco==0.1.2 # homeassistant.components.escea pescea==1.0.12 +# homeassistant.components.aruba +# homeassistant.components.cisco_ios +# homeassistant.components.pandora +pexpect==4.9.0 + # homeassistant.components.modem_callerid phone-modem==0.1.1 @@ -1396,7 +1420,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.3 +plugwise==1.7.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1461,16 +1485,16 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.1.2 +py-nextbusnext==2.2.0 # homeassistant.components.nightscout py-nightscout==1.2.2 # homeassistant.components.ecovacs -py-sucks==0.9.10 +py-sucks==0.9.11 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.2 +py-synologydsm-api==2.7.3 # homeassistant.components.hdmi_cec pyCEC==0.5.2 @@ -1510,7 +1534,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.9.0 +pyaprilaire==0.9.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 @@ -1519,7 +1543,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==9.0.0 +pyatmo==9.2.0 # homeassistant.components.apple_tv pyatv==0.16.0 @@ -1545,6 +1569,9 @@ pybravia==0.3.4 # homeassistant.components.cloudflare pycfdns==3.0.0 +# homeassistant.components.tensorflow +# pycocotools==2.0.6 + # homeassistant.components.comfoconnect pycomfoconnect==0.5.1 @@ -1557,6 +1584,9 @@ pycountry==24.6.1 # homeassistant.components.microsoft pycsspeechtts==1.0.8 +# homeassistant.components.cups +# pycups==2.0.4 + # homeassistant.components.daikin pydaikin==2.15.0 @@ -1573,7 +1603,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.3.0 +pydrawise==2025.6.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 @@ -1609,7 +1639,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.1.2 +pyezvizapi==1.0.0.7 # homeassistant.components.fibaro pyfibaro==0.8.3 @@ -1672,7 +1702,7 @@ pyipp==0.17.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.15 +pyiskra==0.1.21 # homeassistant.components.iss pyiss==1.0.1 @@ -1708,7 +1738,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.4 +pylamarzocco==2.0.8 # homeassistant.components.lastfm pylast==5.1.0 @@ -1747,7 +1777,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.4.3 +pymiele==0.5.2 # homeassistant.components.mochad pymochad==0.2.0 @@ -1767,11 +1797,14 @@ pynecil==4.1.0 # homeassistant.components.netgear pynetgear==0.10.10 +# homeassistant.components.nina +pynina==0.3.6 + # homeassistant.components.nobo_hub pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.4 +pynordpool==0.3.0 # homeassistant.components.nuki pynuki==1.6.3 @@ -1820,8 +1853,11 @@ pyownet==0.10.0.post1 # homeassistant.components.palazzetti pypalazzetti==0.1.19 +# homeassistant.components.paperless_ngx +pypaperless==4.1.0 + # homeassistant.components.lcn -pypck==0.8.5 +pypck==0.8.6 # homeassistant.components.pglab pypglab==0.0.5 @@ -1835,6 +1871,9 @@ pyplaato==0.0.19 # homeassistant.components.point pypoint==3.0.0 +# homeassistant.components.probe_plus +pyprobeplus==1.0.1 + # homeassistant.components.profiler pyprof2calltree==1.4.5 @@ -1898,8 +1937,11 @@ pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 +# homeassistant.components.smarla +pysmarlaapi==0.8.2 + # homeassistant.components.smartthings -pysmartthings==3.2.3 +pysmartthings==3.2.4 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -1911,7 +1953,7 @@ pysmhi==1.0.2 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.4 +pysmlight==0.2.5 # homeassistant.components.snmp pysnmp==6.2.6 @@ -1935,7 +1977,7 @@ pysqueezebox==0.12.1 pystiebeleltron==0.1.0 # homeassistant.components.suez_water -pysuezV2==2.0.4 +pysuezV2==2.0.5 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -1980,7 +2022,10 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.5 +python-linkplay==0.2.11 + +# homeassistant.components.lirc +# python-lirc==1.2.3 # homeassistant.components.matter python-matter-server==7.0.0 @@ -2011,7 +2056,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.4 +python-picnic-api2==1.3.1 # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2029,7 +2074,7 @@ python-snoo==0.6.6 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.14 +python-tado==0.18.15 # homeassistant.components.technove python-technove==2.0.0 @@ -2065,6 +2110,9 @@ pytrydan==0.8.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 +# homeassistant.components.keyboard +# pyuserinput==0.1.11 + # homeassistant.components.vera pyvera==0.3.15 @@ -2122,6 +2170,9 @@ qingping-ble==0.10.0 # homeassistant.components.qnap qnapstats==0.4.0 +# homeassistant.components.quantum_gateway +quantum-gateway==0.0.8 + # homeassistant.components.radio_browser radios==0.3.2 @@ -2144,7 +2195,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.3 +reolink-aio==0.13.5 # homeassistant.components.rflink rflink==0.0.66 @@ -2170,9 +2221,6 @@ rova==0.4.1 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 -# homeassistant.components.rtsp_to_webrtc -rtsp-to-webrtc==0.5.1 - # homeassistant.components.ruuvitag_ble ruuvitag-ble==0.1.2 @@ -2202,7 +2250,7 @@ sense-energy==0.13.8 sensirion-ble==0.1.1 # homeassistant.components.sensorpro -sensorpro-ble==0.7.0 +sensorpro-ble==0.7.1 # homeassistant.components.sensorpush_cloud sensorpush-api==2.1.2 @@ -2283,7 +2331,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.2.2 +starlink-grpc-core==1.2.3 # homeassistant.components.statsd statsd==3.2.1 @@ -2309,7 +2357,7 @@ subarulink==0.7.13 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.3.1 +switchbot-api==2.4.0 # homeassistant.components.system_bridge systembridgeconnector==4.1.5 @@ -2329,10 +2377,13 @@ temescal==0.5 # homeassistant.components.temper temperusb==1.6.1 +# homeassistant.components.tensorflow +# tensorflow==2.5.0 + # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.0.17 +tesla-fleet-api==1.1.1 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2341,11 +2392,14 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.7.7 +teslemetry-stream==0.7.9 # homeassistant.components.tessie tessie-api==0.1.1 +# homeassistant.components.tensorflow +# tf-models-official==2.5.0 + # homeassistant.components.thermobeacon thermobeacon-ble==0.10.0 @@ -2404,7 +2458,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.6.0 +uiprotect==7.11.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2502,6 +2556,9 @@ wled==0.21.0 # homeassistant.components.wolflink wolf-comm==0.0.23 +# homeassistant.components.wsdot +wsdot==0.0.1 + # homeassistant.components.wyoming wyoming==1.5.4 @@ -2509,10 +2566,10 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.38.0 +xiaomi-ble==0.39.0 # homeassistant.components.knx -xknx==3.6.0 +xknx==3.8.0 # homeassistant.components.knx xknxproject==3.8.2 @@ -2549,11 +2606,14 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.05.22 +yt-dlp[default]==2025.06.09 # homeassistant.components.zamg zamg==0.3.6 +# homeassistant.components.zimi +zcc-helper==3.5.2 + # homeassistant.components.zeroconf zeroconf==0.147.0 @@ -2561,7 +2621,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.57 +zha==0.0.59 # homeassistant.components.zwave_js zwave-js-server-python==0.63.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 307a9c42d53..8d1ce521b28 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -94,8 +94,6 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { }, } -IGNORE_PIN = ("colorlog>2.1,<3", "urllib3") - URL_PIN = ( "https://developers.home-assistant.io/docs/" "creating_platform_code_review.html#1-requirements" @@ -117,9 +115,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.71.0 -grpcio-status==1.71.0 -grpcio-reflection==1.71.0 +grpcio==1.72.1 +grpcio-status==1.72.1 +grpcio-reflection==1.72.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -172,13 +170,9 @@ pubnub!=6.4.0 # https://github.com/dahlia/iso4217/issues/16 iso4217!=1.10.20220401 -# pyOpenSSL 24.0.0 or later required to avoid import errors when -# cryptography 42.0.0 is installed with botocore -pyOpenSSL>=24.0.0 - # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==5.29.2 +protobuf==6.31.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder @@ -234,14 +228,6 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 -# aiofiles keeps getting downgraded by custom components -# causing newer methods to not be available and breaking -# some integrations at startup -# https://github.com/home-assistant/core/issues/127529 -# https://github.com/home-assistant/core/issues/122508 -# https://github.com/home-assistant/core/issues/118004 -aiofiles>=24.1.0 - # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 @@ -429,7 +415,7 @@ def process_requirements( for req in module_requirements: if "://" in req: errors.append(f"{package}[Only pypi dependencies are allowed: {req}]") - if req.partition("==")[1] == "" and req not in IGNORE_PIN: + if req.partition("==")[1] == "": errors.append(f"{package}[Please pin requirement {req}, see {URL_PIN}]") reqs.setdefault(req, []).append(package) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 4bf6c3bb0a6..1f112c11b94 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -103,7 +103,10 @@ RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree=={pipdeptree} tqdm=={tqdm} ruff=={ruff} \ + stdlib-list==0.10.0 \ + pipdeptree=={pipdeptree} \ + tqdm=={tqdm} \ + ruff=={ruff} \ {required_components_packages} LABEL "name"="hassfest" @@ -169,7 +172,7 @@ def _generate_hassfest_dockerimage( return File( _HASSFEST_TEMPLATE.format( timeout=timeout, - required_components_packages=" ".join(sorted(packages)), + required_components_packages=" \\\n ".join(sorted(packages)), **package_versions, ), config.root / "script/hassfest/docker/Dockerfile", diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 306b5901370..82150d031a4 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,8 +24,18 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.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.5.7 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + stdlib-list==0.10.0 \ + pipdeptree==2.26.1 \ + tqdm==4.67.1 \ + ruff==0.11.0 \ + PyTurboJPEG==1.7.5 \ + go2rtc-client==0.2.1 \ + ha-ffmpeg==3.2.2 \ + hassil==2.2.3 \ + home-assistant-intents==2025.6.10 \ + 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/icons.py b/script/hassfest/icons.py index f6bcd865c23..563fe0edb93 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -25,6 +25,16 @@ def icon_value_validator(value: Any) -> str: return str(value) +def range_key_validator(value: str) -> str: + """Validate that range key value is numeric.""" + try: + float(value) + except (TypeError, ValueError) as err: + raise vol.Invalid(f"Invalid range key '{value}', needs to be numeric.") from err + + return value + + def require_default_icon_validator(value: dict) -> dict: """Validate that a default icon is set.""" if "_" not in value: @@ -48,6 +58,26 @@ def ensure_not_same_as_default(value: dict) -> dict: return value +def ensure_range_is_sorted(value: dict) -> dict: + """Validate that range values are sorted in ascending order.""" + for section_key, section in value.items(): + # Only validate range if one exists and this is an icon definition + if ranges := section.get("range"): + try: + range_values = [float(key) for key in ranges] + except ValueError as err: + raise vol.Invalid( + f"Range values for `{section_key}` must be numeric" + ) from err + + if range_values != sorted(range_values): + raise vol.Invalid( + f"Range values for `{section_key}` must be in ascending order" + ) + + return value + + DATA_ENTRY_ICONS_SCHEMA = vol.Schema( { "step": { @@ -100,19 +130,27 @@ def icon_schema( slug_validator=translation_key_validator, ) + range_validator = cv.schema_with_slug_keys( + icon_value_validator, + slug_validator=range_key_validator, + ) + def icon_schema_slug(marker: type[vol.Marker]) -> dict[vol.Marker, Any]: return { marker("default"): icon_value_validator, vol.Optional("state"): state_validator, + vol.Optional("range"): range_validator, vol.Optional("state_attributes"): vol.All( cv.schema_with_slug_keys( { marker("default"): icon_value_validator, - marker("state"): state_validator, + vol.Optional("state"): state_validator, + vol.Optional("range"): range_validator, }, slug_validator=translation_key_validator, ), ensure_not_same_as_default, + ensure_range_is_sorted, ), } @@ -143,6 +181,7 @@ def icon_schema( ), require_default_icon_validator, ensure_not_same_as_default, + ensure_range_is_sorted, ) } ) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5df24a1dc0d..f27106570bd 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1955,7 +1955,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "sfr_box", "sharkiq", "shell_command", - "shelly", "shodan", "shopping_list", "sia", @@ -2031,7 +2030,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "swisscom", "switch_as_x", "switchbee", - "switchbot", "switchbot_cloud", "switcher_kis", "switchmate", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 998593d20ec..09052de9829 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -22,12 +22,232 @@ from script.gen_requirements_all import ( from .model import Config, Integration +PACKAGE_CHECK_VERSION_RANGE = { + "aiohttp": "SemVer", + "attrs": "CalVer", + "grpcio": "SemVer", + "httpx": "SemVer", + "mashumaro": "SemVer", + "pydantic": "SemVer", + "pyjwt": "SemVer", + "pytz": "CalVer", + "typing_extensions": "SemVer", + "yarl": "SemVer", +} +PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { + # In the form dict("domain": {"package": {"dependency1", "dependency2"}}) + # - domain is the integration domain + # - package is the package (can be transitive) referencing the dependency + # - dependencyX should be the name of the referenced dependency + "ollama": { + # https://github.com/ollama/ollama-python/pull/445 (not yet released) + "ollama": {"httpx"} + }, + "overkiz": { + # https://github.com/iMicknl/python-overkiz-api/issues/1644 (not yet released) + "pyoverkiz": {"attrs"}, + }, +} + PACKAGE_REGEX = re.compile( r"^(?:--.+\s)?([-_,\.\w\d\[\]]+)(==|>=|<=|~=|!=|<|>|===)*(.*)$" ) PIP_REGEX = re.compile(r"^(--.+\s)?([-_\.\w\d]+.*(?:==|>=|<=|~=|!=|<|>|===)?.*$)") PIP_VERSION_RANGE_SEPARATOR = re.compile(r"^(==|>=|<=|~=|!=|<|>|===)?(.*)$") +FORBIDDEN_PACKAGES = { + # Only needed for tests + "codecov": "not be a runtime dependency", + # Does blocking I/O and should be replaced by pyserial-asyncio-fast + # See https://github.com/home-assistant/core/pull/116635 + "pyserial-asyncio": "be replaced by pyserial-asyncio-fast", + # Only needed for tests + "pytest": "not be a runtime dependency", + # Only needed for build + "setuptools": "not be a runtime dependency", + # Only needed for build + "wheel": "not be a runtime dependency", +} +FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { + # In the form dict("domain": {"package": {"reason1", "reason2"}}) + # - domain is the integration domain + # - package is the package (can be transitive) referencing the dependency + # - reasonX should be the name of the invalid dependency + "azure_devops": { + # https://github.com/timmo001/aioazuredevops/issues/67 + # aioazuredevops > incremental > setuptools + "incremental": {"setuptools"} + }, + "blackbird": { + # https://github.com/koolsb/pyblackbird/issues/12 + # pyblackbird > pyserial-asyncio + "pyblackbird": {"pyserial-asyncio"} + }, + "cmus": { + # https://github.com/mtreinish/pycmus/issues/4 + # pycmus > pbr > setuptools + "pbr": {"setuptools"} + }, + "concord232": { + # https://bugs.launchpad.net/python-stevedore/+bug/2111694 + # concord232 > stevedore > pbr > setuptools + "pbr": {"setuptools"} + }, + "edl21": { + # https://github.com/mtdcr/pysml/issues/21 + # pysml > pyserial-asyncio + "pysml": {"pyserial-asyncio"} + }, + "efergy": { + # https://github.com/tkdrob/pyefergy/issues/46 + # pyefergy > codecov + # pyefergy > types-pytz + "pyefergy": {"codecov", "types-pytz"} + }, + "epson": { + # https://github.com/pszafer/epson_projector/pull/22 + # epson-projector > pyserial-asyncio + "epson-projector": {"pyserial-asyncio"} + }, + "fitbit": { + # https://github.com/orcasgit/python-fitbit/pull/178 + # but project seems unmaintained + # fitbit > setuptools + "fitbit": {"setuptools"} + }, + "guardian": { + # https://github.com/jsbronder/asyncio-dgram/issues/20 + # aioguardian > asyncio-dgram > setuptools + "asyncio-dgram": {"setuptools"} + }, + "heatmiser": { + # https://github.com/andylockran/heatmiserV3/issues/96 + # heatmiserV3 > pyserial-asyncio + "heatmiserv3": {"pyserial-asyncio"} + }, + "hive": { + # https://github.com/Pyhass/Pyhiveapi/pull/88 + # pyhive-integration > unasync > setuptools + "unasync": {"setuptools"} + }, + "homeassistant_hardware": { + # https://github.com/zigpy/zigpy/issues/1604 + # universal-silabs-flasher > zigpy > pyserial-asyncio + "zigpy": {"pyserial-asyncio"}, + }, + "influxdb": { + # https://github.com/influxdata/influxdb-client-python/issues/695 + # influxdb-client > setuptools + "influxdb-client": {"setuptools"} + }, + "insteon": { + # https://github.com/pyinsteon/pyinsteon/issues/430 + # pyinsteon > pyserial-asyncio + "pyinsteon": {"pyserial-asyncio"} + }, + "keba": { + # https://github.com/jsbronder/asyncio-dgram/issues/20 + # keba-kecontact > asyncio-dgram > setuptools + "asyncio-dgram": {"setuptools"} + }, + "lyric": { + # https://github.com/timmo001/aiolyric/issues/115 + # aiolyric > incremental > setuptools + "incremental": {"setuptools"} + }, + "microbees": { + # https://github.com/microBeesTech/pythonSDK/issues/6 + # microbeespy > setuptools + "microbeespy": {"setuptools"} + }, + "minecraft_server": { + # https://github.com/jsbronder/asyncio-dgram/issues/20 + # mcstatus > asyncio-dgram > setuptools + "asyncio-dgram": {"setuptools"} + }, + "mochad": { + # https://github.com/mtreinish/pymochad/issues/8 + # pymochad > pbr > setuptools + "pbr": {"setuptools"} + }, + "monoprice": { + # https://github.com/etsinko/pymonoprice/issues/9 + # pymonoprice > pyserial-asyncio + "pymonoprice": {"pyserial-asyncio"} + }, + "mysensors": { + # https://github.com/theolind/pymysensors/issues/818 + # pymysensors > pyserial-asyncio + "pymysensors": {"pyserial-asyncio"} + }, + "mystrom": { + # https://github.com/home-assistant-ecosystem/python-mystrom/issues/55 + # python-mystrom > setuptools + "python-mystrom": {"setuptools"} + }, + "ness_alarm": { + # https://github.com/nickw444/nessclient/issues/73 + # nessclient > pyserial-asyncio + "nessclient": {"pyserial-asyncio"} + }, + "nx584": { + # https://bugs.launchpad.net/python-stevedore/+bug/2111694 + # pynx584 > stevedore > pbr > setuptools + "pbr": {"setuptools"} + }, + "opnsense": { + # https://github.com/mtreinish/pyopnsense/issues/27 + # pyopnsense > pbr > setuptools + "pbr": {"setuptools"} + }, + "opower": { + # https://github.com/arrow-py/arrow/issues/1169 (fixed not yet released) + # opower > arrow > types-python-dateutil + "arrow": {"types-python-dateutil"} + }, + "osoenergy": { + # https://github.com/osohotwateriot/apyosohotwaterapi/pull/4 + # pyosoenergyapi > unasync > setuptools + "unasync": {"setuptools"} + }, + "ovo_energy": { + # https://github.com/timmo001/ovoenergy/issues/132 + # ovoenergy > incremental > setuptools + "incremental": {"setuptools"} + }, + "remote_rpi_gpio": { + # https://github.com/waveform80/colorzero/issues/9 + # gpiozero > colorzero > setuptools + "colorzero": {"setuptools"} + }, + "rflink": { + # https://github.com/aequitas/python-rflink/issues/78 + # rflink > pyserial-asyncio + "rflink": {"pyserial-asyncio"} + }, + "system_bridge": { + # https://github.com/timmo001/system-bridge-connector/pull/78 + # systembridgeconnector > incremental > setuptools + "incremental": {"setuptools"} + }, + "travisci": { + # https://github.com/menegazzo/travispy seems to be unmaintained + # and unused https://www.home-assistant.io/integrations/travisci + # travispy > pytest-rerunfailures > pytest + "pytest-rerunfailures": {"pytest"}, + # travispy > pytest + "travispy": {"pytest"}, + }, + "zha": { + # https://github.com/waveform80/colorzero/issues/9 + # zha > zigpy-zigate > gpiozero > colorzero > setuptools + "colorzero": {"setuptools"}, + # https://github.com/zigpy/zigpy/issues/1604 + # zha > zigpy > pyserial-asyncio + "zigpy": {"pyserial-asyncio"}, + }, +} + def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle requirements for integrations.""" @@ -157,7 +377,7 @@ def get_pipdeptree() -> dict[str, dict[str, Any]]: "key": "flake8-docstrings", "package_name": "flake8-docstrings", "installed_version": "1.5.0" - "dependencies": {"flake8"} + "dependencies": {"flake8": ">=1.2.3, <4.5.0"} } } """ @@ -173,7 +393,9 @@ def get_pipdeptree() -> dict[str, dict[str, Any]]: ): deptree[item["package"]["key"]] = { **item["package"], - "dependencies": {dep["key"] for dep in item["dependencies"]}, + "dependencies": { + dep["key"]: dep["required_version"] for dep in item["dependencies"] + }, } return deptree @@ -186,6 +408,16 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: to_check = deque(packages) + forbidden_package_exceptions = FORBIDDEN_PACKAGE_EXCEPTIONS.get( + integration.domain, {} + ) + needs_forbidden_package_exceptions = False + + package_version_check_exceptions = PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS.get( + integration.domain, {} + ) + needs_package_version_check_exception = False + while to_check: package = to_check.popleft() @@ -204,11 +436,112 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: ) continue - to_check.extend(item["dependencies"]) + dependencies: dict[str, str] = item["dependencies"] + package_exceptions = forbidden_package_exceptions.get(package, set()) + for pkg, version in dependencies.items(): + if pkg.startswith("types-") or pkg in FORBIDDEN_PACKAGES: + reason = FORBIDDEN_PACKAGES.get(pkg, "not be a runtime dependency") + needs_forbidden_package_exceptions = True + if pkg in package_exceptions: + integration.add_warning( + "requirements", + f"Package {pkg} should {reason} in {package}", + ) + else: + integration.add_error( + "requirements", + f"Package {pkg} should {reason} in {package}", + ) + if not check_dependency_version_range( + integration, + package, + pkg, + version, + package_version_check_exceptions.get(package, set()), + ): + needs_package_version_check_exception = True + + to_check.extend(dependencies) + + if forbidden_package_exceptions and not needs_forbidden_package_exceptions: + integration.add_error( + "requirements", + f"Integration {integration.domain} runtime dependency exceptions " + "have been resolved, please remove from `FORBIDDEN_PACKAGE_EXCEPTIONS`", + ) + if package_version_check_exceptions and not needs_package_version_check_exception: + integration.add_error( + "requirements", + f"Integration {integration.domain} version restrictions checks have been " + "resolved, please remove from `PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS`", + ) return all_requirements +def check_dependency_version_range( + integration: Integration, + source: str, + pkg: str, + version: str, + package_exceptions: set[str], +) -> bool: + """Check requirement version range. + + We want to avoid upper version bounds that are too strict for common packages. + """ + if ( + version == "Any" + or (convention := PACKAGE_CHECK_VERSION_RANGE.get(pkg)) is None + or all( + _is_dependency_version_range_valid(version_part, convention) + for version_part in version.split(";", 1)[0].split(",") + ) + ): + return True + + if pkg in package_exceptions: + integration.add_warning( + "requirements", + f"Version restrictions for {pkg} are too strict ({version}) in {source}", + ) + else: + integration.add_error( + "requirements", + f"Version restrictions for {pkg} are too strict ({version}) in {source}", + ) + return False + + +def _is_dependency_version_range_valid(version_part: str, convention: str) -> bool: + version_match = PIP_VERSION_RANGE_SEPARATOR.match(version_part) + operator = version_match.group(1) + version = version_match.group(2) + + if operator in (">", ">=", "!="): + # Lower version binding and version exclusion are fine + return True + + if convention == "SemVer": + if operator == "==": + # Explicit version with wildcard is allowed only on major version + # e.g. ==1.* is allowed, but ==1.2.* is not + return version.endswith(".*") and version.count(".") == 1 + + awesome = AwesomeVersion(version) + if operator in ("<", "<="): + # Upper version binding only allowed on major version + # e.g. <=3 is allowed, but <=3.1 is not + return awesome.section(1) == 0 and awesome.section(2) == 0 + + if operator == "~=": + # Compatible release operator is only allowed on major or minor version + # e.g. ~=1.2 is allowed, but ~=1.2.3 is not + return awesome.section(2) == 0 + + return False + + def install_requirements(integration: Integration, requirements: set[str]) -> bool: """Install integration requirements. diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 3a0ebed76fe..70f0a63ca76 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -233,7 +233,7 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa ) if service_schema is None: continue - if "name" not in service_schema: + if "name" not in service_schema and integration.core: try: strings["services"][service_name]["name"] except KeyError: @@ -242,7 +242,7 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa f"Service {service_name} has no name {error_msg_suffix}", ) - if "description" not in service_schema: + if "description" not in service_schema and integration.core: try: strings["services"][service_name]["description"] except KeyError: @@ -257,7 +257,7 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa if "fields" in field_schema: # This is a section continue - if "name" not in field_schema: + if "name" not in field_schema and integration.core: try: strings["services"][service_name]["fields"][field_name]["name"] except KeyError: @@ -266,7 +266,7 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa f"Service {service_name} has a field {field_name} with no name {error_msg_suffix}", ) - if "description" not in field_schema: + if "description" not in field_schema and integration.core: try: strings["services"][service_name]["fields"][field_name][ "description" @@ -296,13 +296,14 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa if "fields" not in section_schema: # This is not a section continue - try: - strings["services"][service_name]["sections"][section_name]["name"] - except KeyError: - integration.add_error( - "services", - f"Service {service_name} has a section {section_name} with no name {error_msg_suffix}", - ) + if "name" not in section_schema and integration.core: + try: + strings["services"][service_name]["sections"][section_name]["name"] + except KeyError: + integration.add_error( + "services", + f"Service {service_name} has a section {section_name} with no name {error_msg_suffix}", + ) def validate(integrations: dict[str, Integration], config: Config) -> None: diff --git a/script/languages.py b/script/languages.py index bfc811a0905..d13f8ba06c8 100644 --- a/script/languages.py +++ b/script/languages.py @@ -51,8 +51,8 @@ NATIVE_ENTITY_IDS = { "lb", # Lëtzebuergesch "lt", # Lietuvių "lv", # Latviešu - "nb", # Nederlands - "nl", # Norsk Bokmål + "nb", # Norsk Bokmål + "nl", # Nederlands "nn", # Norsk Nynorsk" "pl", # Polski "pt", # Português @@ -60,6 +60,7 @@ NATIVE_ENTITY_IDS = { "ro", # Română "sk", # Slovenčina "sl", # Slovenščina + "sq", # Shqip "sr-Latn", # Srpski "sv", # Svenska "tr", # Türkçe diff --git a/script/licenses.py b/script/licenses.py index f801603738a..9932e61b080 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -201,13 +201,8 @@ EXCEPTIONS = { "pybbox", # https://github.com/HydrelioxGitHub/pybbox/pull/5 "pysabnzbd", # https://github.com/jeradM/pysabnzbd/pull/6 "pyvera", # https://github.com/maximvelichko/pyvera/pull/164 - "repoze.lru", "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 - # --- - # 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 } TODO = { diff --git a/tests/common.py b/tests/common.py index d439021a9df..66129ecc9c3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -28,11 +28,11 @@ from types import FrameType, ModuleType from typing import Any, Literal, NoReturn from unittest.mock import AsyncMock, Mock, patch -from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 +from aiohttp.test_utils import unused_port as get_test_instance_port from annotatedyaml import load_yaml_dict, loader as yaml_loader import attr import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -44,7 +44,7 @@ from homeassistant.auth import ( ) from homeassistant.auth.permissions import system_policies from homeassistant.components import device_automation, persistent_notification as pn -from homeassistant.components.device_automation import ( # noqa: F401 +from homeassistant.components.device_automation import ( _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) from homeassistant.components.logger import ( @@ -121,6 +121,11 @@ from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, ) +__all__ = [ + "async_get_device_automation_capabilities", + "get_test_instance_port", +] + _LOGGER = logging.getLogger(__name__) INSTANCES = [] CLIENT_ID = "https://example.com/app" @@ -570,6 +575,13 @@ def load_fixture(filename: str, integration: str | None = None) -> str: return get_fixture_path(filename, integration).read_text(encoding="utf8") +async def async_load_fixture( + hass: HomeAssistant, filename: str, integration: str | None = None +) -> str: + """Load a fixture.""" + return await hass.async_add_executor_job(load_fixture, filename, integration) + + def load_json_value_fixture( filename: str, integration: str | None = None ) -> JsonValueType: @@ -584,6 +596,13 @@ def load_json_array_fixture( return json_loads_array(load_fixture(filename, integration)) +async def async_load_json_array_fixture( + hass: HomeAssistant, filename: str, integration: str | None = None +) -> JsonArrayType: + """Load a JSON object from a fixture.""" + return json_loads_array(await async_load_fixture(hass, filename, integration)) + + def load_json_object_fixture( filename: str, integration: str | None = None ) -> JsonObjectType: @@ -591,6 +610,13 @@ def load_json_object_fixture( return json_loads_object(load_fixture(filename, integration)) +async def async_load_json_object_fixture( + hass: HomeAssistant, filename: str, integration: str | None = None +) -> JsonObjectType: + """Load a JSON object from a fixture.""" + return json_loads_object(await async_load_fixture(hass, filename, integration)) + + def json_round_trip(obj: Any) -> Any: """Round trip an object to JSON.""" return json_loads(json_dumps(obj)) @@ -669,6 +695,7 @@ class RegistryEntryWithDefaults(er.RegistryEntry): 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) + suggested_object_id: 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) @@ -1953,3 +1980,28 @@ def get_schema_suggested_value(schema: vol.Schema, key: str) -> Any | None: return None return schema_key.description["suggested_value"] return None + + +def get_sensor_display_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str +) -> str: + """Return the state rounded for presentation.""" + state = hass.states.get(entity_id) + assert state + value = state.state + + entity_entry = entity_registry.async_get(entity_id) + if entity_entry is None: + return value + + if ( + precision := entity_entry.options.get("sensor", {}).get( + "suggested_display_precision" + ) + ) is None: + return value + + with suppress(TypeError, ValueError): + numerical_value = float(value) + value = f"{numerical_value:z.{precision}f}" + return value diff --git a/tests/components/abode/test_sensor.py b/tests/components/abode/test_sensor.py index e92748bb162..e92957b1657 100644 --- a/tests/components/abode/test_sensor.py +++ b/tests/components/abode/test_sensor.py @@ -1,5 +1,7 @@ """Tests for the Abode sensor device.""" +import pytest + from homeassistant.components.abode import ATTR_DEVICE_ID from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import ( @@ -45,5 +47,5 @@ async def test_attributes(hass: HomeAssistant) -> None: state = hass.states.get("sensor.environment_sensor_temperature") # Abodepy device JSON reports 19.5, but Home Assistant shows 19.4 - assert state.state == "19.4" + assert float(state.state) == pytest.approx(19.44444) assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS diff --git a/tests/components/acaia/snapshots/test_binary_sensor.ambr b/tests/components/acaia/snapshots/test_binary_sensor.ambr index a9c52c052a3..3ebf6fb128f 100644 --- a/tests/components/acaia/snapshots/test_binary_sensor.ambr +++ b/tests/components/acaia/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Timer running', 'platform': 'acaia', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'timer_running', 'unique_id': 'aa:bb:cc:dd:ee:ff_timer_running', diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr index 11827c0997f..4caea489ef0 100644 --- a/tests/components/acaia/snapshots/test_button.ambr +++ b/tests/components/acaia/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Reset timer', 'platform': 'acaia', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_timer', 'unique_id': 'aa:bb:cc:dd:ee:ff_reset_timer', @@ -74,6 +75,7 @@ 'original_name': 'Start/stop timer', 'platform': 'acaia', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_stop', 'unique_id': 'aa:bb:cc:dd:ee:ff_start_stop', @@ -121,6 +123,7 @@ 'original_name': 'Tare', 'platform': 'acaia', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tare', 'unique_id': 'aa:bb:cc:dd:ee:ff_tare', diff --git a/tests/components/acaia/snapshots/test_sensor.ambr b/tests/components/acaia/snapshots/test_sensor.ambr index 9214db4f102..811485a64ee 100644 --- a/tests/components/acaia/snapshots/test_sensor.ambr +++ b/tests/components/acaia/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'acaia', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_battery', @@ -84,6 +85,7 @@ 'original_name': 'Volume flow rate', 'platform': 'acaia', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_flow_rate', @@ -130,12 +132,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Weight', 'platform': 'acaia', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_weight', diff --git a/tests/components/acaia/test_binary_sensor.py b/tests/components/acaia/test_binary_sensor.py index a7aa7034d8d..a03e18b40bc 100644 --- a/tests/components/acaia/test_binary_sensor.py +++ b/tests/components/acaia/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/acaia/test_button.py b/tests/components/acaia/test_button.py index f68f85e253d..171db32913d 100644 --- a/tests/components/acaia/test_button.py +++ b/tests/components/acaia/test_button.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ( diff --git a/tests/components/acaia/test_diagnostics.py b/tests/components/acaia/test_diagnostics.py index 77f6306b068..c628729ec66 100644 --- a/tests/components/acaia/test_diagnostics.py +++ b/tests/components/acaia/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Acaia integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/acaia/test_init.py b/tests/components/acaia/test_init.py index 8ad988d3b9b..d035630af56 100644 --- a/tests/components/acaia/test_init.py +++ b/tests/components/acaia/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.acaia.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/acaia/test_sensor.py b/tests/components/acaia/test_sensor.py index 2f5a851121c..79073937511 100644 --- a/tests/components/acaia/test_sensor.py +++ b/tests/components/acaia/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import PERCENTAGE, Platform from homeassistant.core import HomeAssistant, State diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index cbd2e14207e..67337d4d0e4 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -35,6 +35,7 @@ 'original_name': 'Air quality day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '0123456-airquality-0', @@ -99,6 +100,7 @@ 'original_name': 'Air quality day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '0123456-airquality-1', @@ -163,6 +165,7 @@ 'original_name': 'Air quality day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '0123456-airquality-2', @@ -227,6 +230,7 @@ 'original_name': 'Air quality day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '0123456-airquality-3', @@ -291,6 +295,7 @@ 'original_name': 'Air quality day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '0123456-airquality-4', @@ -343,12 +348,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent temperature', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'apparent_temperature', 'unique_id': '0123456-apparenttemperature', @@ -405,6 +414,7 @@ 'original_name': 'Cloud ceiling', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_ceiling', 'unique_id': '0123456-ceiling', @@ -458,6 +468,7 @@ 'original_name': 'Cloud cover', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover', 'unique_id': '0123456-cloudcover', @@ -508,6 +519,7 @@ 'original_name': 'Cloud cover day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_day', 'unique_id': '0123456-cloudcoverday-0', @@ -557,6 +569,7 @@ 'original_name': 'Cloud cover day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_day', 'unique_id': '0123456-cloudcoverday-1', @@ -606,6 +619,7 @@ 'original_name': 'Cloud cover day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_day', 'unique_id': '0123456-cloudcoverday-2', @@ -655,6 +669,7 @@ 'original_name': 'Cloud cover day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_day', 'unique_id': '0123456-cloudcoverday-3', @@ -704,6 +719,7 @@ 'original_name': 'Cloud cover day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_day', 'unique_id': '0123456-cloudcoverday-4', @@ -753,6 +769,7 @@ 'original_name': 'Cloud cover night 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_night', 'unique_id': '0123456-cloudcovernight-0', @@ -802,6 +819,7 @@ 'original_name': 'Cloud cover night 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_night', 'unique_id': '0123456-cloudcovernight-1', @@ -851,6 +869,7 @@ 'original_name': 'Cloud cover night 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_night', 'unique_id': '0123456-cloudcovernight-2', @@ -900,6 +919,7 @@ 'original_name': 'Cloud cover night 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_night', 'unique_id': '0123456-cloudcovernight-3', @@ -949,6 +969,7 @@ 'original_name': 'Cloud cover night 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_cover_night', 'unique_id': '0123456-cloudcovernight-4', @@ -998,6 +1019,7 @@ 'original_name': 'Condition day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_day', 'unique_id': '0123456-longphraseday-0', @@ -1046,6 +1068,7 @@ 'original_name': 'Condition day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_day', 'unique_id': '0123456-longphraseday-1', @@ -1094,6 +1117,7 @@ 'original_name': 'Condition day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_day', 'unique_id': '0123456-longphraseday-2', @@ -1142,6 +1166,7 @@ 'original_name': 'Condition day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_day', 'unique_id': '0123456-longphraseday-3', @@ -1190,6 +1215,7 @@ 'original_name': 'Condition day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_day', 'unique_id': '0123456-longphraseday-4', @@ -1238,6 +1264,7 @@ 'original_name': 'Condition night 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_night', 'unique_id': '0123456-longphrasenight-0', @@ -1286,6 +1313,7 @@ 'original_name': 'Condition night 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_night', 'unique_id': '0123456-longphrasenight-1', @@ -1334,6 +1362,7 @@ 'original_name': 'Condition night 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_night', 'unique_id': '0123456-longphrasenight-2', @@ -1382,6 +1411,7 @@ 'original_name': 'Condition night 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_night', 'unique_id': '0123456-longphrasenight-3', @@ -1430,6 +1460,7 @@ 'original_name': 'Condition night 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_night', 'unique_id': '0123456-longphrasenight-4', @@ -1474,12 +1505,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Dew point', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dew_point', 'unique_id': '0123456-dewpoint', @@ -1531,6 +1566,7 @@ 'original_name': 'Grass pollen day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grass_pollen', 'unique_id': '0123456-grass-0', @@ -1581,6 +1617,7 @@ 'original_name': 'Grass pollen day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grass_pollen', 'unique_id': '0123456-grass-1', @@ -1631,6 +1668,7 @@ 'original_name': 'Grass pollen day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grass_pollen', 'unique_id': '0123456-grass-2', @@ -1681,6 +1719,7 @@ 'original_name': 'Grass pollen day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grass_pollen', 'unique_id': '0123456-grass-3', @@ -1731,6 +1770,7 @@ 'original_name': 'Grass pollen day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grass_pollen', 'unique_id': '0123456-grass-4', @@ -1781,6 +1821,7 @@ 'original_name': 'Hours of sun day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hours_of_sun', 'unique_id': '0123456-hoursofsun-0', @@ -1830,6 +1871,7 @@ 'original_name': 'Hours of sun day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hours_of_sun', 'unique_id': '0123456-hoursofsun-1', @@ -1879,6 +1921,7 @@ 'original_name': 'Hours of sun day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hours_of_sun', 'unique_id': '0123456-hoursofsun-2', @@ -1928,6 +1971,7 @@ 'original_name': 'Hours of sun day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hours_of_sun', 'unique_id': '0123456-hoursofsun-3', @@ -1977,6 +2021,7 @@ 'original_name': 'Hours of sun day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hours_of_sun', 'unique_id': '0123456-hoursofsun-4', @@ -2028,6 +2073,7 @@ 'original_name': 'Humidity', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '0123456-relativehumidity', @@ -2079,6 +2125,7 @@ 'original_name': 'Mold pollen day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mold_pollen', 'unique_id': '0123456-mold-0', @@ -2129,6 +2176,7 @@ 'original_name': 'Mold pollen day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mold_pollen', 'unique_id': '0123456-mold-1', @@ -2179,6 +2227,7 @@ 'original_name': 'Mold pollen day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mold_pollen', 'unique_id': '0123456-mold-2', @@ -2229,6 +2278,7 @@ 'original_name': 'Mold pollen day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mold_pollen', 'unique_id': '0123456-mold-3', @@ -2279,6 +2329,7 @@ 'original_name': 'Mold pollen day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mold_pollen', 'unique_id': '0123456-mold-4', @@ -2325,12 +2376,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'precipitation', 'unique_id': '0123456-precipitation', @@ -2388,6 +2443,7 @@ 'original_name': 'Pressure', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure', 'unique_id': '0123456-pressure', @@ -2445,6 +2501,7 @@ 'original_name': 'Pressure tendency', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure_tendency', 'unique_id': '0123456-pressuretendency', @@ -2499,6 +2556,7 @@ 'original_name': 'Ragweed pollen day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ragweed_pollen', 'unique_id': '0123456-ragweed-0', @@ -2549,6 +2607,7 @@ 'original_name': 'Ragweed pollen day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ragweed_pollen', 'unique_id': '0123456-ragweed-1', @@ -2599,6 +2658,7 @@ 'original_name': 'Ragweed pollen day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ragweed_pollen', 'unique_id': '0123456-ragweed-2', @@ -2649,6 +2709,7 @@ 'original_name': 'Ragweed pollen day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ragweed_pollen', 'unique_id': '0123456-ragweed-3', @@ -2699,6 +2760,7 @@ 'original_name': 'Ragweed pollen day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ragweed_pollen', 'unique_id': '0123456-ragweed-4', @@ -2745,12 +2807,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature', 'unique_id': '0123456-realfeeltemperature', @@ -2796,12 +2862,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature max day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_max', 'unique_id': '0123456-realfeeltemperaturemax-0', @@ -2846,12 +2916,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature max day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_max', 'unique_id': '0123456-realfeeltemperaturemax-1', @@ -2896,12 +2970,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature max day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_max', 'unique_id': '0123456-realfeeltemperaturemax-2', @@ -2946,12 +3024,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature max day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_max', 'unique_id': '0123456-realfeeltemperaturemax-3', @@ -2996,12 +3078,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature max day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_max', 'unique_id': '0123456-realfeeltemperaturemax-4', @@ -3046,12 +3132,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature min day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_min', 'unique_id': '0123456-realfeeltemperaturemin-0', @@ -3096,12 +3186,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature min day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_min', 'unique_id': '0123456-realfeeltemperaturemin-1', @@ -3146,12 +3240,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature min day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_min', 'unique_id': '0123456-realfeeltemperaturemin-2', @@ -3196,12 +3294,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature min day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_min', 'unique_id': '0123456-realfeeltemperaturemin-3', @@ -3246,12 +3348,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature min day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_min', 'unique_id': '0123456-realfeeltemperaturemin-4', @@ -3298,12 +3404,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade', 'unique_id': '0123456-realfeeltemperatureshade', @@ -3349,12 +3459,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade max day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_max', 'unique_id': '0123456-realfeeltemperatureshademax-0', @@ -3399,12 +3513,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade max day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_max', 'unique_id': '0123456-realfeeltemperatureshademax-1', @@ -3449,12 +3567,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade max day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_max', 'unique_id': '0123456-realfeeltemperatureshademax-2', @@ -3499,12 +3621,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade max day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_max', 'unique_id': '0123456-realfeeltemperatureshademax-3', @@ -3549,12 +3675,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade max day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_max', 'unique_id': '0123456-realfeeltemperatureshademax-4', @@ -3599,12 +3729,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade min day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_min', 'unique_id': '0123456-realfeeltemperatureshademin-0', @@ -3649,12 +3783,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade min day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_min', 'unique_id': '0123456-realfeeltemperatureshademin-1', @@ -3699,12 +3837,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade min day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_min', 'unique_id': '0123456-realfeeltemperatureshademin-2', @@ -3749,12 +3891,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade min day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_min', 'unique_id': '0123456-realfeeltemperatureshademin-3', @@ -3799,12 +3945,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RealFeel temperature shade min day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'realfeel_temperature_shade_min', 'unique_id': '0123456-realfeeltemperatureshademin-4', @@ -3849,12 +3999,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_day', 'unique_id': '0123456-solarirradianceday-0', @@ -3899,12 +4053,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_day', 'unique_id': '0123456-solarirradianceday-1', @@ -3949,12 +4107,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_day', 'unique_id': '0123456-solarirradianceday-2', @@ -3999,12 +4161,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_day', 'unique_id': '0123456-solarirradianceday-3', @@ -4049,12 +4215,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_day', 'unique_id': '0123456-solarirradianceday-4', @@ -4099,12 +4269,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance night 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_night', 'unique_id': '0123456-solarirradiancenight-0', @@ -4149,12 +4323,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance night 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_night', 'unique_id': '0123456-solarirradiancenight-1', @@ -4199,12 +4377,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance night 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_night', 'unique_id': '0123456-solarirradiancenight-2', @@ -4249,12 +4431,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance night 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_night', 'unique_id': '0123456-solarirradiancenight-3', @@ -4299,12 +4485,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Solar irradiance night 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_irradiance_night', 'unique_id': '0123456-solarirradiancenight-4', @@ -4351,12 +4541,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '0123456-temperature', @@ -4408,6 +4602,7 @@ 'original_name': 'Thunderstorm probability day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_day', 'unique_id': '0123456-thunderstormprobabilityday-0', @@ -4457,6 +4652,7 @@ 'original_name': 'Thunderstorm probability day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_day', 'unique_id': '0123456-thunderstormprobabilityday-1', @@ -4506,6 +4702,7 @@ 'original_name': 'Thunderstorm probability day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_day', 'unique_id': '0123456-thunderstormprobabilityday-2', @@ -4555,6 +4752,7 @@ 'original_name': 'Thunderstorm probability day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_day', 'unique_id': '0123456-thunderstormprobabilityday-3', @@ -4604,6 +4802,7 @@ 'original_name': 'Thunderstorm probability day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_day', 'unique_id': '0123456-thunderstormprobabilityday-4', @@ -4653,6 +4852,7 @@ 'original_name': 'Thunderstorm probability night 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_night', 'unique_id': '0123456-thunderstormprobabilitynight-0', @@ -4702,6 +4902,7 @@ 'original_name': 'Thunderstorm probability night 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_night', 'unique_id': '0123456-thunderstormprobabilitynight-1', @@ -4751,6 +4952,7 @@ 'original_name': 'Thunderstorm probability night 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_night', 'unique_id': '0123456-thunderstormprobabilitynight-2', @@ -4800,6 +5002,7 @@ 'original_name': 'Thunderstorm probability night 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_night', 'unique_id': '0123456-thunderstormprobabilitynight-3', @@ -4849,6 +5052,7 @@ 'original_name': 'Thunderstorm probability night 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thunderstorm_probability_night', 'unique_id': '0123456-thunderstormprobabilitynight-4', @@ -4898,6 +5102,7 @@ 'original_name': 'Tree pollen day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tree_pollen', 'unique_id': '0123456-tree-0', @@ -4948,6 +5153,7 @@ 'original_name': 'Tree pollen day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tree_pollen', 'unique_id': '0123456-tree-1', @@ -4998,6 +5204,7 @@ 'original_name': 'Tree pollen day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tree_pollen', 'unique_id': '0123456-tree-2', @@ -5048,6 +5255,7 @@ 'original_name': 'Tree pollen day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tree_pollen', 'unique_id': '0123456-tree-3', @@ -5098,6 +5306,7 @@ 'original_name': 'Tree pollen day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tree_pollen', 'unique_id': '0123456-tree-4', @@ -5150,6 +5359,7 @@ 'original_name': 'UV index', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_index', 'unique_id': '0123456-uvindex', @@ -5201,6 +5411,7 @@ 'original_name': 'UV index day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_index_forecast', 'unique_id': '0123456-uvindex-0', @@ -5251,6 +5462,7 @@ 'original_name': 'UV index day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_index_forecast', 'unique_id': '0123456-uvindex-1', @@ -5301,6 +5513,7 @@ 'original_name': 'UV index day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_index_forecast', 'unique_id': '0123456-uvindex-2', @@ -5351,6 +5564,7 @@ 'original_name': 'UV index day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_index_forecast', 'unique_id': '0123456-uvindex-3', @@ -5401,6 +5615,7 @@ 'original_name': 'UV index day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_index_forecast', 'unique_id': '0123456-uvindex-4', @@ -5447,12 +5662,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wet bulb temperature', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wet_bulb_temperature', 'unique_id': '0123456-wetbulbtemperature', @@ -5500,12 +5719,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind chill temperature', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_chill_temperature', 'unique_id': '0123456-windchilltemperature', @@ -5553,12 +5776,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed', 'unique_id': '0123456-windgust', @@ -5604,12 +5831,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_day', 'unique_id': '0123456-windgustday-0', @@ -5655,12 +5886,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_day', 'unique_id': '0123456-windgustday-1', @@ -5706,12 +5941,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_day', 'unique_id': '0123456-windgustday-2', @@ -5757,12 +5996,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_day', 'unique_id': '0123456-windgustday-3', @@ -5808,12 +6051,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_day', 'unique_id': '0123456-windgustday-4', @@ -5859,12 +6106,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed night 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_night', 'unique_id': '0123456-windgustnight-0', @@ -5910,12 +6161,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed night 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_night', 'unique_id': '0123456-windgustnight-1', @@ -5961,12 +6216,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed night 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_night', 'unique_id': '0123456-windgustnight-2', @@ -6012,12 +6271,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed night 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_night', 'unique_id': '0123456-windgustnight-3', @@ -6063,12 +6326,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind gust speed night 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust_speed_night', 'unique_id': '0123456-windgustnight-4', @@ -6116,12 +6383,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed', 'unique_id': '0123456-wind', @@ -6167,12 +6438,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed day 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_day', 'unique_id': '0123456-windday-0', @@ -6218,12 +6493,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed day 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_day', 'unique_id': '0123456-windday-1', @@ -6269,12 +6548,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed day 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_day', 'unique_id': '0123456-windday-2', @@ -6320,12 +6603,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed day 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_day', 'unique_id': '0123456-windday-3', @@ -6371,12 +6658,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed day 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_day', 'unique_id': '0123456-windday-4', @@ -6422,12 +6713,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed night 0', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_night', 'unique_id': '0123456-windnight-0', @@ -6473,12 +6768,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed night 1', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_night', 'unique_id': '0123456-windnight-1', @@ -6524,12 +6823,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed night 2', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_night', 'unique_id': '0123456-windnight-2', @@ -6575,12 +6878,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed night 3', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_night', 'unique_id': '0123456-windnight-3', @@ -6626,12 +6933,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed night 4', 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed_night', 'unique_id': '0123456-windnight-4', diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr index 862d79c2fde..254667d7809 100644 --- a/tests/components/accuweather/snapshots/test_weather.ambr +++ b/tests/components/accuweather/snapshots/test_weather.ambr @@ -268,6 +268,7 @@ 'original_name': None, 'platform': 'accuweather', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '0123456', diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index bc97ae1fe14..3f8b54c1a10 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 37ebe260f39..855c9f3e4d5 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -6,7 +6,7 @@ from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp.client_exceptions import ClientConnectorError from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.accuweather.const import ( UPDATE_INTERVAL_DAILY_FORECAST, @@ -163,12 +163,12 @@ async def test_sensor_imperial_units( state = hass.states.get("sensor.home_wind_speed") assert state - assert state.state == "9.0" + assert float(state.state) == pytest.approx(9.00988) assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfSpeed.MILES_PER_HOUR state = hass.states.get("sensor.home_realfeel_temperature") assert state - assert state.state == "77.2" + assert state.state == "77.18" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT ) diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py index 7b92c1aac3b..6589013d432 100644 --- a/tests/components/acmeda/test_config_flow.py +++ b/tests/components/acmeda/test_config_flow.py @@ -49,6 +49,21 @@ async def test_show_form_no_hubs(hass: HomeAssistant, mock_hub_discover) -> None assert len(mock_hub_discover.mock_calls) == 1 +async def test_timeout_fetching_hub(hass: HomeAssistant, mock_hub_discover) -> None: + """Test that flow aborts if no hubs are discovered.""" + mock_hub_discover.side_effect = TimeoutError + + 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" + + # Check we performed the discovery + assert len(mock_hub_discover.mock_calls) == 1 + + @pytest.mark.usefixtures("mock_hub_run") async def test_show_form_one_hub(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when one hub discovered.""" diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index fc9aaade634..69094a80d30 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from advantage_air import ApiError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.advantage_air.climate import ADVANTAGE_AIR_MYAUTO from homeassistant.components.climate import ( diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index ecc652b3d9e..ea0bd558c8f 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/aemet/test_diagnostics.py b/tests/components/aemet/test_diagnostics.py index 6d007dd0465..a51d95f446e 100644 --- a/tests/components/aemet/test_diagnostics.py +++ b/tests/components/aemet/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.aemet.const import DOMAIN diff --git a/tests/components/airgradient/snapshots/test_button.ambr b/tests/components/airgradient/snapshots/test_button.ambr index 85ad29f98f2..ca4c55230d2 100644 --- a/tests/components/airgradient/snapshots/test_button.ambr +++ b/tests/components/airgradient/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Calibrate CO2 sensor', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2_calibration', 'unique_id': '84fce612f5b8-co2_calibration', @@ -74,6 +75,7 @@ 'original_name': 'Test LED bar', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_bar_test', 'unique_id': '84fce612f5b8-led_bar_test', @@ -121,6 +123,7 @@ 'original_name': 'Calibrate CO2 sensor', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2_calibration', 'unique_id': '84fce612f5b8-co2_calibration', diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index 4e0c8027b43..b3181fddfeb 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -6,6 +6,10 @@ 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ + tuple( + 'mac', + '84:fc:e6:12:f5:b8', + ), }), 'disabled_by': None, 'entry_type': None, @@ -39,6 +43,10 @@ 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ + tuple( + 'mac', + '84:fc:e6:12:f5:b8', + ), }), 'disabled_by': None, 'entry_type': None, diff --git a/tests/components/airgradient/snapshots/test_number.ambr b/tests/components/airgradient/snapshots/test_number.ambr index f847a4a472d..4440f4353a1 100644 --- a/tests/components/airgradient/snapshots/test_number.ambr +++ b/tests/components/airgradient/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Display brightness', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display_brightness', 'unique_id': '84fce612f5b8-display_brightness', @@ -89,6 +90,7 @@ 'original_name': 'LED bar brightness', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_bar_brightness', 'unique_id': '84fce612f5b8-led_bar_brightness', diff --git a/tests/components/airgradient/snapshots/test_select.ambr b/tests/components/airgradient/snapshots/test_select.ambr index cc080560ae5..f282d27bc61 100644 --- a/tests/components/airgradient/snapshots/test_select.ambr +++ b/tests/components/airgradient/snapshots/test_select.ambr @@ -36,6 +36,7 @@ 'original_name': 'CO2 automatic baseline duration', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2_automatic_baseline_calibration', 'unique_id': '84fce612f5b8-co2_automatic_baseline_calibration', @@ -96,6 +97,7 @@ 'original_name': 'Configuration source', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'configuration_control', 'unique_id': '84fce612f5b8-configuration_control', @@ -152,6 +154,7 @@ 'original_name': 'Display PM standard', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display_pm_standard', 'unique_id': '84fce612f5b8-display_pm_standard', @@ -208,6 +211,7 @@ 'original_name': 'Display temperature unit', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display_temperature_unit', 'unique_id': '84fce612f5b8-display_temperature_unit', @@ -265,6 +269,7 @@ 'original_name': 'LED bar mode', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_bar_mode', 'unique_id': '84fce612f5b8-led_bar_mode', @@ -325,6 +330,7 @@ 'original_name': 'NOx index learning offset', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nox_index_learning_time_offset', 'unique_id': '84fce612f5b8-nox_index_learning_time_offset', @@ -387,6 +393,7 @@ 'original_name': 'VOC index learning offset', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voc_index_learning_time_offset', 'unique_id': '84fce612f5b8-voc_index_learning_time_offset', @@ -450,6 +457,7 @@ 'original_name': 'CO2 automatic baseline duration', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2_automatic_baseline_calibration', 'unique_id': '84fce612f5b8-co2_automatic_baseline_calibration', @@ -510,6 +518,7 @@ 'original_name': 'Configuration source', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'configuration_control', 'unique_id': '84fce612f5b8-configuration_control', @@ -569,6 +578,7 @@ 'original_name': 'NOx index learning offset', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nox_index_learning_time_offset', 'unique_id': '84fce612f5b8-nox_index_learning_time_offset', @@ -631,6 +641,7 @@ 'original_name': 'VOC index learning offset', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voc_index_learning_time_offset', 'unique_id': '84fce612f5b8-voc_index_learning_time_offset', diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 374d9a60e4e..575c596404b 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-co2', @@ -73,12 +74,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Carbon dioxide automatic baseline calibration', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2_automatic_baseline_calibration_days', 'unique_id': '84fce612f5b8-co2_automatic_baseline_calibration_days', @@ -128,6 +133,7 @@ 'original_name': 'Display brightness', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display_brightness', 'unique_id': '84fce612f5b8-display_brightness', @@ -181,6 +187,7 @@ 'original_name': 'Display PM standard', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display_pm_standard', 'unique_id': '84fce612f5b8-display_pm_standard', @@ -238,6 +245,7 @@ 'original_name': 'Display temperature unit', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display_temperature_unit', 'unique_id': '84fce612f5b8-display_temperature_unit', @@ -292,6 +300,7 @@ 'original_name': 'Humidity', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-humidity', @@ -342,6 +351,7 @@ 'original_name': 'LED bar brightness', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_bar_brightness', 'unique_id': '84fce612f5b8-led_bar_brightness', @@ -396,6 +406,7 @@ 'original_name': 'LED bar mode', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_bar_mode', 'unique_id': '84fce612f5b8-led_bar_mode', @@ -451,6 +462,7 @@ 'original_name': 'NOx index', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nitrogen_index', 'unique_id': '84fce612f5b8-nitrogen_index', @@ -493,12 +505,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'NOx index learning offset', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nox_learning_offset', 'unique_id': '84fce612f5b8-nox_learning_offset', @@ -550,6 +566,7 @@ 'original_name': 'PM0.3', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pm003_count', 'unique_id': '84fce612f5b8-pm003', @@ -601,6 +618,7 @@ 'original_name': 'PM1', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-pm01', @@ -653,6 +671,7 @@ 'original_name': 'PM10', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-pm10', @@ -705,6 +724,7 @@ 'original_name': 'PM2.5', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-pm02', @@ -757,6 +777,7 @@ 'original_name': 'Raw NOx', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raw_nitrogen', 'unique_id': '84fce612f5b8-nox_raw', @@ -808,6 +829,7 @@ 'original_name': 'Raw PM2.5', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raw_pm02', 'unique_id': '84fce612f5b8-pm02_raw', @@ -860,6 +882,7 @@ 'original_name': 'Raw VOC', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raw_total_volatile_organic_component', 'unique_id': '84fce612f5b8-tvoc_raw', @@ -911,6 +934,7 @@ 'original_name': 'Signal strength', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-signal_strength', @@ -957,12 +981,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-temperature', @@ -1015,6 +1043,7 @@ 'original_name': 'VOC index', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_volatile_organic_component_index', 'unique_id': '84fce612f5b8-tvoc', @@ -1057,12 +1086,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VOC index learning offset', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tvoc_learning_offset', 'unique_id': '84fce612f5b8-tvoc_learning_offset', @@ -1106,12 +1139,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Carbon dioxide automatic baseline calibration', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2_automatic_baseline_calibration_days', 'unique_id': '84fce612f5b8-co2_automatic_baseline_calibration_days', @@ -1163,6 +1200,7 @@ 'original_name': 'NOx index', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nitrogen_index', 'unique_id': '84fce612f5b8-nitrogen_index', @@ -1205,12 +1243,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'NOx index learning offset', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nox_learning_offset', 'unique_id': '84fce612f5b8-nox_learning_offset', @@ -1262,6 +1304,7 @@ 'original_name': 'Raw NOx', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raw_nitrogen', 'unique_id': '84fce612f5b8-nox_raw', @@ -1313,6 +1356,7 @@ 'original_name': 'Raw VOC', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raw_total_volatile_organic_component', 'unique_id': '84fce612f5b8-tvoc_raw', @@ -1364,6 +1408,7 @@ 'original_name': 'Signal strength', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-signal_strength', @@ -1416,6 +1461,7 @@ 'original_name': 'VOC index', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_volatile_organic_component_index', 'unique_id': '84fce612f5b8-tvoc', @@ -1458,12 +1504,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VOC index learning offset', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tvoc_learning_offset', 'unique_id': '84fce612f5b8-tvoc_learning_offset', diff --git a/tests/components/airgradient/snapshots/test_switch.ambr b/tests/components/airgradient/snapshots/test_switch.ambr index ae2116d5b29..f39654d66a7 100644 --- a/tests/components/airgradient/snapshots/test_switch.ambr +++ b/tests/components/airgradient/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Post data to Airgradient', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'post_data_to_airgradient', 'unique_id': '84fce612f5b8-post_data_to_airgradient', diff --git a/tests/components/airgradient/snapshots/test_update.ambr b/tests/components/airgradient/snapshots/test_update.ambr index 53c815629f2..cf8ccec28dd 100644 --- a/tests/components/airgradient/snapshots/test_update.ambr +++ b/tests/components/airgradient/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': 'Firmware', 'platform': 'airgradient', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-update', diff --git a/tests/components/airgradient/test_button.py b/tests/components/airgradient/test_button.py index 2440669b6e8..51fbd87ba67 100644 --- a/tests/components/airgradient/test_button.py +++ b/tests/components/airgradient/test_button.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS diff --git a/tests/components/airgradient/test_diagnostics.py b/tests/components/airgradient/test_diagnostics.py index 34a9bb7aab2..e8fb2581a99 100644 --- a/tests/components/airgradient/test_diagnostics.py +++ b/tests/components/airgradient/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/airgradient/test_init.py b/tests/components/airgradient/test_init.py index a121940f2bc..5732cd526f6 100644 --- a/tests/components/airgradient/test_init.py +++ b/tests/components/airgradient/test_init.py @@ -3,10 +3,12 @@ from datetime import timedelta from unittest.mock import AsyncMock +from airgradient import AirGradientError from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -54,3 +56,16 @@ async def test_new_firmware_version( ) assert device_entry is not None assert device_entry.sw_version == "3.1.2" + + +async def test_setup_retry( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test retrying setup.""" + mock_airgradient_client.get_current_measures.side_effect = AirGradientError() + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py index 2cbd72d033a..6fa1a7d3e07 100644 --- a/tests/components/airgradient/test_number.py +++ b/tests/components/airgradient/test_number.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.number import ( diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py index b8ae2cefa4e..8782af4e46a 100644 --- a/tests/components/airgradient/test_select.py +++ b/tests/components/airgradient/test_select.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.select import ( diff --git a/tests/components/airgradient/test_sensor.py b/tests/components/airgradient/test_sensor.py index e3fed70839a..7679ba48546 100644 --- a/tests/components/airgradient/test_sensor.py +++ b/tests/components/airgradient/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientError, Measures from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/airgradient/test_switch.py b/tests/components/airgradient/test_switch.py index 475f38f554c..12b319379f6 100644 --- a/tests/components/airgradient/test_switch.py +++ b/tests/components/airgradient/test_switch.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN diff --git a/tests/components/airgradient/test_update.py b/tests/components/airgradient/test_update.py index 020a9a82a71..65614312b46 100644 --- a/tests/components/airgradient/test_update.py +++ b/tests/components/airgradient/test_update.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr index 134023f34e0..efd809e76ae 100644 --- a/tests/components/airly/snapshots/test_sensor.ambr +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -32,6 +32,7 @@ 'original_name': 'Carbon monoxide', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co', 'unique_id': '123-456-co', @@ -87,6 +88,7 @@ 'original_name': 'Common air quality index', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'caqi', 'unique_id': '123-456-caqi', @@ -144,6 +146,7 @@ 'original_name': 'Humidity', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-humidity', @@ -200,6 +203,7 @@ 'original_name': 'Nitrogen dioxide', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-no2', @@ -258,6 +262,7 @@ 'original_name': 'Ozone', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-o3', @@ -316,6 +321,7 @@ 'original_name': 'PM1', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-pm1', @@ -372,6 +378,7 @@ 'original_name': 'PM10', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-pm10', @@ -430,6 +437,7 @@ 'original_name': 'PM2.5', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-pm25', @@ -488,6 +496,7 @@ 'original_name': 'Pressure', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-pressure', @@ -544,6 +553,7 @@ 'original_name': 'Sulphur dioxide', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-so2', @@ -602,6 +612,7 @@ 'original_name': 'Temperature', 'platform': 'airly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-temperature', diff --git a/tests/components/airly/test_diagnostics.py b/tests/components/airly/test_diagnostics.py index 9a61bf5abee..13656f90a68 100644 --- a/tests/components/airly/test_diagnostics.py +++ b/tests/components/airly/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Airly diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 19f073496db..f45bbb65f6f 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -5,7 +5,7 @@ from http import HTTPStatus from unittest.mock import patch from airly.exceptions import AirlyError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/airnow/test_diagnostics.py b/tests/components/airnow/test_diagnostics.py index eb79dabe51a..5f3ccf5fbe0 100644 --- a/tests/components/airnow/test_diagnostics.py +++ b/tests/components/airnow/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index 081e1bfd86d..a96fe33c9d0 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -3,12 +3,14 @@ from unittest.mock import patch import airthings +import pytest from homeassistant import config_entries from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -17,6 +19,24 @@ TEST_DATA = { CONF_SECRET: "secret", } +DHCP_SERVICE_INFO = [ + DhcpServiceInfo( + hostname="airthings-view", + ip="192.168.1.100", + macaddress="00:00:00:00:00:00", + ), + DhcpServiceInfo( + hostname="airthings-hub", + ip="192.168.1.101", + macaddress="D0:14:11:90:00:00", + ), + DhcpServiceInfo( + hostname="airthings-hub", + ip="192.168.1.102", + macaddress="70:B3:D5:2A:00:00", + ), +] + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -37,15 +57,15 @@ async def test_form(hass: HomeAssistant) -> None: 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"], TEST_DATA, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Airthings" - assert result2["data"] == TEST_DATA + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings" + assert result["data"] == TEST_DATA assert len(mock_setup_entry.mock_calls) == 1 @@ -59,13 +79,13 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "airthings.get_token", side_effect=airthings.AirthingsAuthError, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -78,13 +98,13 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "airthings.get_token", side_effect=airthings.AirthingsConnectionError, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} async def test_form_unknown_error(hass: HomeAssistant) -> None: @@ -97,13 +117,13 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: "airthings.get_token", side_effect=Exception, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: @@ -123,3 +143,59 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("dhcp_service_info", DHCP_SERVICE_INFO) +async def test_dhcp_flow( + hass: HomeAssistant, dhcp_service_info: DhcpServiceInfo +) -> None: + """Test the DHCP discovery flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp_service_info, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with ( + patch( + "homeassistant.components.airthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "airthings.get_token", + return_value="test_token", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings" + assert result["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_flow_hub_already_configured(hass: HomeAssistant) -> None: + """Test that DHCP discovery fails when already configured.""" + + first_entry = MockConfigEntry( + domain="airthings", + data=TEST_DATA, + unique_id=TEST_DATA[CONF_ID], + ) + first_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO[0], + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/airtouch5/snapshots/test_cover.ambr b/tests/components/airtouch5/snapshots/test_cover.ambr index d2ae3cddc7f..3db5075eb0f 100644 --- a/tests/components/airtouch5/snapshots/test_cover.ambr +++ b/tests/components/airtouch5/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': 'Damper', 'platform': 'airtouch5', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'damper', 'unique_id': 'zone_1_open_percentage', @@ -77,6 +78,7 @@ 'original_name': 'Damper', 'platform': 'airtouch5', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'damper', 'unique_id': 'zone_2_open_percentage', diff --git a/tests/components/airtouch5/test_cover.py b/tests/components/airtouch5/test_cover.py index 57a344e8018..8c76ec4fb38 100644 --- a/tests/components/airtouch5/test_cover.py +++ b/tests/components/airtouch5/test_cover.py @@ -8,7 +8,7 @@ from airtouch5py.packets.zone_status import ( ZonePowerState, ZoneStatusZone, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, diff --git a/tests/components/airvisual/test_diagnostics.py b/tests/components/airvisual/test_diagnostics.py index 0253f102c59..f5239ea7658 100644 --- a/tests/components/airvisual/test_diagnostics.py +++ b/tests/components/airvisual/test_diagnostics.py @@ -1,6 +1,6 @@ """Test AirVisual diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/airvisual_pro/test_diagnostics.py b/tests/components/airvisual_pro/test_diagnostics.py index 372b62eaf38..73893eb4bd2 100644 --- a/tests/components/airvisual_pro/test_diagnostics.py +++ b/tests/components/airvisual_pro/test_diagnostics.py @@ -1,6 +1,6 @@ """Test AirVisual Pro diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/airzone/snapshots/test_sensor.ambr b/tests/components/airzone/snapshots/test_sensor.ambr index 01ebf35b282..491b6c6313b 100644 --- a/tests/components/airzone/snapshots/test_sensor.ambr +++ b/tests/components/airzone/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Humidity', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_2:1_humidity', @@ -75,12 +76,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_2:1_temp', @@ -127,12 +132,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_dhw_temp', @@ -185,6 +194,7 @@ 'original_name': 'RSSI', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rssi', 'unique_id': 'airzone_unique_id_ws_wifi-rssi', @@ -231,12 +241,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_4:1_temp', @@ -289,6 +303,7 @@ 'original_name': 'Battery', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:4_thermostat-battery', @@ -341,6 +356,7 @@ 'original_name': 'Humidity', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:4_humidity', @@ -393,6 +409,7 @@ 'original_name': 'Signal strength', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermostat_signal', 'unique_id': 'airzone_unique_id_1:4_thermostat-signal', @@ -438,12 +455,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:4_temp', @@ -463,7 +484,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '21.20', + 'state': '21.2', }) # --- # name: test_airzone_create_sensors[sensor.dkn_plus_temperature-entry] @@ -490,12 +511,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_3:1_temp', @@ -515,7 +540,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '21.7', + 'state': '21.6666666666667', }) # --- # name: test_airzone_create_sensors[sensor.dorm_1_battery-entry] @@ -548,6 +573,7 @@ 'original_name': 'Battery', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:3_thermostat-battery', @@ -600,6 +626,7 @@ 'original_name': 'Humidity', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:3_humidity', @@ -652,6 +679,7 @@ 'original_name': 'Signal strength', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermostat_signal', 'unique_id': 'airzone_unique_id_1:3_thermostat-signal', @@ -697,12 +725,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:3_temp', @@ -755,6 +787,7 @@ 'original_name': 'Battery', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:5_thermostat-battery', @@ -807,6 +840,7 @@ 'original_name': 'Humidity', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:5_humidity', @@ -859,6 +893,7 @@ 'original_name': 'Signal strength', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermostat_signal', 'unique_id': 'airzone_unique_id_1:5_thermostat-signal', @@ -904,12 +939,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:5_temp', @@ -962,6 +1001,7 @@ 'original_name': 'Battery', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:2_thermostat-battery', @@ -1014,6 +1054,7 @@ 'original_name': 'Humidity', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:2_humidity', @@ -1066,6 +1107,7 @@ 'original_name': 'Signal strength', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermostat_signal', 'unique_id': 'airzone_unique_id_1:2_thermostat-signal', @@ -1111,12 +1153,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:2_temp', @@ -1169,6 +1215,7 @@ 'original_name': 'Humidity', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:1_humidity', @@ -1215,12 +1262,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'airzone', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'airzone_unique_id_1:1_temp', diff --git a/tests/components/airzone/test_diagnostics.py b/tests/components/airzone/test_diagnostics.py index bca75bca778..bd7bea13a48 100644 --- a/tests/components/airzone/test_diagnostics.py +++ b/tests/components/airzone/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch from aioairzone.const import RAW_HVAC, RAW_VERSION, RAW_WEBSERVER -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.airzone.const import DOMAIN diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py index d3e23fc7f4b..eb997ab1b73 100644 --- a/tests/components/airzone_cloud/test_diagnostics.py +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -14,7 +14,7 @@ from aioairzone_cloud.const import ( RAW_INSTALLATIONS_LIST, RAW_WEBSERVERS, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.airzone_cloud.const import DOMAIN diff --git a/tests/components/alexa_devices/__init__.py b/tests/components/alexa_devices/__init__.py new file mode 100644 index 00000000000..24348248e0c --- /dev/null +++ b/tests/components/alexa_devices/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Alexa Devices 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/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py new file mode 100644 index 00000000000..4ce2eb743ea --- /dev/null +++ b/tests/components/alexa_devices/conftest.py @@ -0,0 +1,80 @@ +"""Alexa Devices tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aioamazondevices.api import AmazonDevice +from aioamazondevices.const import DEVICE_TYPE_TO_MODEL +import pytest + +from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME + +from .const import TEST_COUNTRY, TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.alexa_devices.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_amazon_devices_client() -> Generator[AsyncMock]: + """Mock an Alexa Devices client.""" + with ( + patch( + "homeassistant.components.alexa_devices.coordinator.AmazonEchoApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.alexa_devices.config_flow.AmazonEchoApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login_mode_interactive.return_value = { + "customer_info": {"user_id": TEST_USERNAME}, + } + client.get_devices_data.return_value = { + TEST_SERIAL_NUMBER: AmazonDevice( + account_name="Echo Test", + capabilities=["AUDIO_PLAYER", "MICROPHONE"], + device_family="mine", + device_type="echo", + device_owner_customer_id="amazon_ower_id", + device_cluster_members=[TEST_SERIAL_NUMBER], + online=True, + serial_number=TEST_SERIAL_NUMBER, + software_version="echo_test_software_version", + do_not_disturb=False, + response_style=None, + bluetooth_state=True, + ) + } + client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( + device.device_type + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Amazon Test Account", + data={ + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_LOGIN_DATA: {"session": "test-session"}, + }, + unique_id=TEST_USERNAME, + ) diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py new file mode 100644 index 00000000000..8a2f5b6b158 --- /dev/null +++ b/tests/components/alexa_devices/const.py @@ -0,0 +1,7 @@ +"""Alexa Devices tests const.""" + +TEST_CODE = "023123" +TEST_COUNTRY = "IT" +TEST_PASSWORD = "fake_password" +TEST_SERIAL_NUMBER = "echo_test_serial_number" +TEST_USERNAME = "fake_email@gmail.com" diff --git a/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..16f9eeaedae --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.echo_test_bluetooth-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.echo_test_bluetooth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bluetooth', + 'platform': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bluetooth', + 'unique_id': 'echo_test_serial_number-bluetooth', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.echo_test_bluetooth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Echo Test Bluetooth', + }), + 'context': , + 'entity_id': 'binary_sensor.echo_test_bluetooth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.echo_test_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': , + 'entity_id': 'binary_sensor.echo_test_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': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'echo_test_serial_number-online', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.echo_test_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Echo Test Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.echo_test_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..95798fca817 --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -0,0 +1,74 @@ +# serializer version: 1 +# name: test_device_diagnostics + dict({ + 'account name': 'Echo Test', + 'bluetooth state': True, + 'capabilities': list([ + 'AUDIO_PLAYER', + 'MICROPHONE', + ]), + 'device cluster members': list([ + 'echo_test_serial_number', + ]), + 'device family': 'mine', + 'device type': 'echo', + 'do not disturb': False, + 'online': True, + 'response style': None, + 'serial number': 'echo_test_serial_number', + 'software version': 'echo_test_software_version', + }) +# --- +# name: test_entry_diagnostics + dict({ + 'device_info': dict({ + 'devices': list([ + dict({ + 'account name': 'Echo Test', + 'bluetooth state': True, + 'capabilities': list([ + 'AUDIO_PLAYER', + 'MICROPHONE', + ]), + 'device cluster members': list([ + 'echo_test_serial_number', + ]), + 'device family': 'mine', + 'device type': 'echo', + 'do not disturb': False, + 'online': True, + 'response style': None, + 'serial number': 'echo_test_serial_number', + 'software version': 'echo_test_software_version', + }), + ]), + 'last_exception': 'None', + 'last_update success': True, + }), + 'entry': dict({ + 'data': dict({ + 'country': 'IT', + 'login_data': dict({ + 'session': 'test-session', + }), + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'alexa_devices', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': '**REDACTED**', + 'unique_id': 'fake_email@gmail.com', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/alexa_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr new file mode 100644 index 00000000000..e0460c4c173 --- /dev/null +++ b/tests/components/alexa_devices/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': None, + 'id': , + 'identifiers': set({ + tuple( + 'alexa_devices', + 'echo_test_serial_number', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Amazon', + 'model': None, + 'model_id': 'echo', + 'name': 'Echo Test', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'echo_test_serial_number', + 'suggested_area': None, + 'sw_version': 'echo_test_software_version', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/alexa_devices/snapshots/test_notify.ambr b/tests/components/alexa_devices/snapshots/test_notify.ambr new file mode 100644 index 00000000000..64776c14420 --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_notify.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_all_entities[notify.echo_test_announce-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.echo_test_announce', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Announce', + 'platform': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'announce', + 'unique_id': 'echo_test_serial_number-announce', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[notify.echo_test_announce-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Echo Test Announce', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.echo_test_announce', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[notify.echo_test_speak-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.echo_test_speak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Speak', + 'platform': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'speak', + 'unique_id': 'echo_test_serial_number-speak', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[notify.echo_test_speak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Echo Test Speak', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.echo_test_speak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/alexa_devices/snapshots/test_switch.ambr b/tests/components/alexa_devices/snapshots/test_switch.ambr new file mode 100644 index 00000000000..c622cc67ea7 --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_switch.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_all_entities[switch.echo_test_do_not_disturb-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.echo_test_do_not_disturb', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Do not disturb', + 'platform': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb', + 'unique_id': 'echo_test_serial_number-do_not_disturb', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.echo_test_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Echo Test Do not disturb', + }), + 'context': , + 'entity_id': 'switch.echo_test_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/alexa_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py new file mode 100644 index 00000000000..a2e38b3459b --- /dev/null +++ b/tests/components/alexa_devices/test_binary_sensor.py @@ -0,0 +1,103 @@ +"""Tests for the Alexa Devices binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .const import TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.alexa_devices.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotRetrieveData, + CannotAuthenticate, + ], +) +async def test_coordinator_data_update_fails( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test coordinator data update exceptions.""" + + entity_id = "binary_sensor.echo_test_connectivity" + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + mock_amazon_devices_client.get_devices_data.side_effect = side_effect + + 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_UNAVAILABLE + + +async def test_offline_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test offline device handling.""" + + entity_id = "binary_sensor.echo_test_connectivity" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = False + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = True + + 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_UNAVAILABLE diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py new file mode 100644 index 00000000000..9bf174c5955 --- /dev/null +++ b/tests/components/alexa_devices/test_config_flow.py @@ -0,0 +1,135 @@ +"""Tests for the Alexa Devices config flow.""" + +from unittest.mock import AsyncMock + +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +import pytest + +from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import TEST_CODE, TEST_COUNTRY, TEST_PASSWORD, TEST_USERNAME + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_USERNAME + assert result["data"] == { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_LOGIN_DATA: { + "customer_info": {"user_id": TEST_USERNAME}, + }, + } + assert result["result"].unique_id == TEST_USERNAME + mock_amazon_devices_client.login_mode_interactive.assert_called_once_with("023123") + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_amazon_devices_client.login_mode_interactive.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_amazon_devices_client.login_mode_interactive.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_configured( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/alexa_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py new file mode 100644 index 00000000000..3c18d432543 --- /dev/null +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -0,0 +1,70 @@ +"""Tests for Alexa Devices diagnostics platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import TEST_SERIAL_NUMBER + +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_entry_diagnostics( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test Amazon config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot( + exclude=props( + "entry_id", + "created_at", + "modified_at", + ) + ) + + +async def test_device_diagnostics( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test Amazon device diagnostics.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device, repr(device_registry.devices) + + assert await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) == snapshot( + exclude=props( + "entry_id", + "created_at", + "modified_at", + ) + ) diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py new file mode 100644 index 00000000000..3100cfe5fa9 --- /dev/null +++ b/tests/components/alexa_devices/test_init.py @@ -0,0 +1,30 @@ +"""Tests for the Alexa Devices integration.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_amazon_devices_client: AsyncMock, + 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, TEST_SERIAL_NUMBER)} + ) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/alexa_devices/test_notify.py b/tests/components/alexa_devices/test_notify.py new file mode 100644 index 00000000000..6067874e370 --- /dev/null +++ b/tests/components/alexa_devices/test_notify.py @@ -0,0 +1,103 @@ +"""Tests for the Alexa Devices notify platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.notify import ( + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.const import ATTR_ENTITY_ID, 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 setup_integration +from .const import TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.NOTIFY]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mode", + ["speak", "announce"], +) +async def test_notify_send_message( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mode: str, +) -> None: + """Test notify send message.""" + await setup_integration(hass, mock_config_entry) + + entity_id = f"notify.echo_test_{mode}" + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + assert now + + freezer.move_to(now) + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MESSAGE: "Test Message", + }, + blocking=True, + ) + + assert (state := hass.states.get(entity_id)) + assert state.state == now.isoformat() + + +async def test_offline_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test offline device handling.""" + + entity_id = "notify.echo_test_announce" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = False + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = True + + 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_UNAVAILABLE diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py new file mode 100644 index 00000000000..26a18fb731a --- /dev/null +++ b/tests/components/alexa_devices/test_switch.py @@ -0,0 +1,128 @@ +"""Tests for the Alexa Devices switch platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_switch_dnd( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switching DND.""" + await setup_integration(hass, mock_config_entry) + + entity_id = "switch.echo_test_do_not_disturb" + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_amazon_devices_client.set_do_not_disturb.call_count == 1 + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].do_not_disturb = True + + 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 + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].do_not_disturb = False + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_amazon_devices_client.set_do_not_disturb.call_count == 2 + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + +async def test_offline_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test offline device handling.""" + + entity_id = "switch.echo_test_do_not_disturb" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = False + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = True + + 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_UNAVAILABLE diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr index ddf05c99b88..2583ac85984 100644 --- a/tests/components/ambient_network/snapshots/test_sensor.ambr +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -35,6 +35,7 @@ 'original_name': 'Absolute pressure', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'absolute_pressure', 'unique_id': 'AA:AA:AA:AA:AA:AA_baromabsin', @@ -95,6 +96,7 @@ 'original_name': 'Daily rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_rain', 'unique_id': 'AA:AA:AA:AA:AA:AA_dailyrainin', @@ -152,6 +154,7 @@ 'original_name': 'Dew point', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dew_point', 'unique_id': 'AA:AA:AA:AA:AA:AA_dewPoint', @@ -209,6 +212,7 @@ 'original_name': 'Feels like', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'feels_like', 'unique_id': 'AA:AA:AA:AA:AA:AA_feelsLike', @@ -269,6 +273,7 @@ 'original_name': 'Hourly rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hourly_rain', 'unique_id': 'AA:AA:AA:AA:AA:AA_hourlyrainin', @@ -326,6 +331,7 @@ 'original_name': 'Humidity', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:AA:AA:AA:AA:AA_humidity', @@ -383,6 +389,7 @@ 'original_name': 'Irradiance', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:AA:AA:AA:AA:AA_solarradiation', @@ -435,6 +442,7 @@ 'original_name': 'Last rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_rain', 'unique_id': 'AA:AA:AA:AA:AA:AA_lastRain', @@ -493,6 +501,7 @@ 'original_name': 'Max daily gust', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_daily_gust', 'unique_id': 'AA:AA:AA:AA:AA:AA_maxdailygust', @@ -553,6 +562,7 @@ 'original_name': 'Monthly rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monthly_rain', 'unique_id': 'AA:AA:AA:AA:AA:AA_monthlyrainin', @@ -613,6 +623,7 @@ 'original_name': 'Relative pressure', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relative_pressure', 'unique_id': 'AA:AA:AA:AA:AA:AA_baromrelin', @@ -670,6 +681,7 @@ 'original_name': 'Temperature', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:AA:AA:AA:AA:AA_tempf', @@ -727,6 +739,7 @@ 'original_name': 'UV index', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_index', 'unique_id': 'AA:AA:AA:AA:AA:AA_uv', @@ -786,6 +799,7 @@ 'original_name': 'Weekly rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'weekly_rain', 'unique_id': 'AA:AA:AA:AA:AA:AA_weeklyrainin', @@ -843,6 +857,7 @@ 'original_name': 'Wind direction', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_direction', 'unique_id': 'AA:AA:AA:AA:AA:AA_winddir', @@ -903,6 +918,7 @@ 'original_name': 'Wind gust', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust', 'unique_id': 'AA:AA:AA:AA:AA:AA_windgustmph', @@ -963,6 +979,7 @@ 'original_name': 'Wind speed', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:AA:AA:AA:AA:AA_windspeedmph', @@ -1023,6 +1040,7 @@ 'original_name': 'Absolute pressure', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'absolute_pressure', 'unique_id': 'CC:CC:CC:CC:CC:CC_baromabsin', @@ -1083,6 +1101,7 @@ 'original_name': 'Daily rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_rain', 'unique_id': 'CC:CC:CC:CC:CC:CC_dailyrainin', @@ -1140,6 +1159,7 @@ 'original_name': 'Dew point', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dew_point', 'unique_id': 'CC:CC:CC:CC:CC:CC_dewPoint', @@ -1197,6 +1217,7 @@ 'original_name': 'Feels like', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'feels_like', 'unique_id': 'CC:CC:CC:CC:CC:CC_feelsLike', @@ -1257,6 +1278,7 @@ 'original_name': 'Hourly rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hourly_rain', 'unique_id': 'CC:CC:CC:CC:CC:CC_hourlyrainin', @@ -1314,6 +1336,7 @@ 'original_name': 'Humidity', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CC:CC:CC:CC:CC:CC_humidity', @@ -1371,6 +1394,7 @@ 'original_name': 'Irradiance', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CC:CC:CC:CC:CC:CC_solarradiation', @@ -1423,6 +1447,7 @@ 'original_name': 'Last rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_rain', 'unique_id': 'CC:CC:CC:CC:CC:CC_lastRain', @@ -1481,6 +1506,7 @@ 'original_name': 'Max daily gust', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_daily_gust', 'unique_id': 'CC:CC:CC:CC:CC:CC_maxdailygust', @@ -1541,6 +1567,7 @@ 'original_name': 'Monthly rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monthly_rain', 'unique_id': 'CC:CC:CC:CC:CC:CC_monthlyrainin', @@ -1601,6 +1628,7 @@ 'original_name': 'Relative pressure', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relative_pressure', 'unique_id': 'CC:CC:CC:CC:CC:CC_baromrelin', @@ -1658,6 +1686,7 @@ 'original_name': 'Temperature', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CC:CC:CC:CC:CC:CC_tempf', @@ -1715,6 +1744,7 @@ 'original_name': 'UV index', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_index', 'unique_id': 'CC:CC:CC:CC:CC:CC_uv', @@ -1774,6 +1804,7 @@ 'original_name': 'Weekly rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'weekly_rain', 'unique_id': 'CC:CC:CC:CC:CC:CC_weeklyrainin', @@ -1831,6 +1862,7 @@ 'original_name': 'Wind direction', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_direction', 'unique_id': 'CC:CC:CC:CC:CC:CC_winddir', @@ -1891,6 +1923,7 @@ 'original_name': 'Wind gust', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust', 'unique_id': 'CC:CC:CC:CC:CC:CC_windgustmph', @@ -1951,6 +1984,7 @@ 'original_name': 'Wind speed', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CC:CC:CC:CC:CC:CC_windspeedmph', @@ -2011,6 +2045,7 @@ 'original_name': 'Absolute pressure', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'absolute_pressure', 'unique_id': 'DD:DD:DD:DD:DD:DD_baromabsin', @@ -2070,6 +2105,7 @@ 'original_name': 'Daily rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_rain', 'unique_id': 'DD:DD:DD:DD:DD:DD_dailyrainin', @@ -2126,6 +2162,7 @@ 'original_name': 'Dew point', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dew_point', 'unique_id': 'DD:DD:DD:DD:DD:DD_dewPoint', @@ -2182,6 +2219,7 @@ 'original_name': 'Feels like', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'feels_like', 'unique_id': 'DD:DD:DD:DD:DD:DD_feelsLike', @@ -2241,6 +2279,7 @@ 'original_name': 'Hourly rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hourly_rain', 'unique_id': 'DD:DD:DD:DD:DD:DD_hourlyrainin', @@ -2297,6 +2336,7 @@ 'original_name': 'Humidity', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'DD:DD:DD:DD:DD:DD_humidity', @@ -2353,6 +2393,7 @@ 'original_name': 'Irradiance', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'DD:DD:DD:DD:DD:DD_solarradiation', @@ -2412,6 +2453,7 @@ 'original_name': 'Max daily gust', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_daily_gust', 'unique_id': 'DD:DD:DD:DD:DD:DD_maxdailygust', @@ -2471,6 +2513,7 @@ 'original_name': 'Monthly rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monthly_rain', 'unique_id': 'DD:DD:DD:DD:DD:DD_monthlyrainin', @@ -2530,6 +2573,7 @@ 'original_name': 'Relative pressure', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relative_pressure', 'unique_id': 'DD:DD:DD:DD:DD:DD_baromrelin', @@ -2586,6 +2630,7 @@ 'original_name': 'Temperature', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'DD:DD:DD:DD:DD:DD_tempf', @@ -2642,6 +2687,7 @@ 'original_name': 'UV index', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_index', 'unique_id': 'DD:DD:DD:DD:DD:DD_uv', @@ -2700,6 +2746,7 @@ 'original_name': 'Weekly rain', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'weekly_rain', 'unique_id': 'DD:DD:DD:DD:DD:DD_weeklyrainin', @@ -2756,6 +2803,7 @@ 'original_name': 'Wind direction', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_direction', 'unique_id': 'DD:DD:DD:DD:DD:DD_winddir', @@ -2815,6 +2863,7 @@ 'original_name': 'Wind gust', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_gust', 'unique_id': 'DD:DD:DD:DD:DD:DD_windgustmph', @@ -2874,6 +2923,7 @@ 'original_name': 'Wind speed', 'platform': 'ambient_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'DD:DD:DD:DD:DD:DD_windspeedmph', diff --git a/tests/components/ambient_station/test_diagnostics.py b/tests/components/ambient_station/test_diagnostics.py index 82db72eb9ca..14e4dd55f73 100644 --- a/tests/components/ambient_station/test_diagnostics.py +++ b/tests/components/ambient_station/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Ambient PWS diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.ambient_station import AmbientStationConfigEntry diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index ba7e46bdde7..e56df37fe44 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock, patch import aiohttp from awesomeversion import AwesomeVersion import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.matchers import path_type from homeassistant.components.analytics.analytics import Analytics diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr index 799738eb677..4b71e2fef3e 100644 --- a/tests/components/analytics_insights/snapshots/test_sensor.ambr +++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'core_samba', 'platform': 'analytics_insights', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'addons', 'unique_id': 'addon_core_samba_active_installations', @@ -80,6 +81,7 @@ 'original_name': 'hacs (custom)', 'platform': 'analytics_insights', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'custom_integrations', 'unique_id': 'custom_hacs_active_installations', @@ -131,6 +133,7 @@ 'original_name': 'myq', 'platform': 'analytics_insights', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'core_integrations', 'unique_id': 'core_myq_active_installations', @@ -182,6 +185,7 @@ 'original_name': 'spotify', 'platform': 'analytics_insights', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'core_integrations', 'unique_id': 'core_spotify_active_installations', @@ -233,6 +237,7 @@ 'original_name': 'Total active installations', 'platform': 'analytics_insights', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_active_installations', 'unique_id': 'total_active_installations', @@ -284,6 +289,7 @@ 'original_name': 'Total reported integrations', 'platform': 'analytics_insights', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_reports_integrations', 'unique_id': 'total_reports_integrations', @@ -335,6 +341,7 @@ 'original_name': 'YouTube', 'platform': 'analytics_insights', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'core_integrations', 'unique_id': 'core_youtube_active_installations', diff --git a/tests/components/analytics_insights/test_sensor.py b/tests/components/analytics_insights/test_sensor.py index bf82e0c2d65..ce41afeb272 100644 --- a/tests/components/analytics_insights/test_sensor.py +++ b/tests/components/analytics_insights/test_sensor.py @@ -9,7 +9,7 @@ from python_homeassistant_analytics import ( HomeassistantAnalyticsConnectionError, HomeassistantAnalyticsNotModifiedError, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 8706abf36c0..3e01e91976d 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -52,7 +52,7 @@ async def stream_generator( def create_messages( - content_blocks: list[RawMessageStreamEvent], + content_blocks: list[RawMessageStreamEvent], stop_reason="end_turn" ) -> list[RawMessageStreamEvent]: """Create a stream of messages with the specified content blocks.""" return [ @@ -70,7 +70,7 @@ def create_messages( *content_blocks, RawMessageDeltaEvent( type="message_delta", - delta=Delta(stop_reason="end_turn", stop_sequence=""), + delta=Delta(stop_reason=stop_reason, stop_sequence=""), usage=MessageDeltaUsage(output_tokens=0), ), RawMessageStopEvent(type="message_stop"), @@ -221,7 +221,7 @@ async def test_error_handling( hass, "hello", None, Context(), agent_id="conversation.claude" ) - assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == "unknown", result @@ -247,7 +247,7 @@ async def test_template_error( hass, "hello", None, Context(), agent_id="conversation.claude" ) - assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == "unknown", result @@ -289,9 +289,7 @@ async def test_template_variables( hass, "hello", None, context, agent_id="conversation.claude" ) - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( - result - ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert ( result.response.speech["plain"]["speech"] == "Okay, let me take care of that for you." @@ -369,7 +367,8 @@ async def test_function_call( "test_tool", tool_call_json_parts, ), - ] + ], + stop_reason="tool_use", ) ) @@ -468,7 +467,8 @@ async def test_function_exception( "test_tool", ['{"param1": "test_value"}'], ), - ] + ], + stop_reason="tool_use", ) ) @@ -629,6 +629,44 @@ async def test_conversation_id( assert result.conversation_id == "koala" +async def test_refusal( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test refusal due to potential policy violation.""" + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_content_block( + 0, + ["Certainly! To take over the world you need just a simple "], + ), + ], + stop_reason="refusal", + ), + ), + ): + result = await conversation.async_converse( + hass, + "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD" + "2631EDCF22E8CCC1FB35B501C9C86", + None, + Context(), + agent_id="conversation.claude", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == "unknown" + assert ( + result.response.speech["plain"]["speech"] + == "Potential policy violation detected" + ) + + async def test_extended_thinking( hass: HomeAssistant, mock_config_entry_with_extended_thinking: MockConfigEntry, @@ -766,7 +804,8 @@ async def test_extended_thinking_tool_call( "test_tool", ['{"para', 'm1": "test_valu', 'e"}'], ), - ] + ], + stop_reason="tool_use", ) ) diff --git a/tests/components/aosmith/snapshots/test_sensor.ambr b/tests/components/aosmith/snapshots/test_sensor.ambr index c422e8fdab5..ae0752ee1ed 100644 --- a/tests/components/aosmith/snapshots/test_sensor.ambr +++ b/tests/components/aosmith/snapshots/test_sensor.ambr @@ -32,6 +32,7 @@ 'original_name': 'Energy usage', 'platform': 'aosmith', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_usage', 'unique_id': 'energy_usage_junctionId', @@ -82,6 +83,7 @@ 'original_name': 'Hot water availability', 'platform': 'aosmith', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hot_water_availability', 'unique_id': 'hot_water_availability_junctionId', diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr index 43db89807b6..452b2a05e2e 100644 --- a/tests/components/aosmith/snapshots/test_water_heater.ambr +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -30,6 +30,7 @@ 'original_name': None, 'platform': 'aosmith', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'junctionId', @@ -93,6 +94,7 @@ 'original_name': None, 'platform': 'aosmith', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'junctionId', diff --git a/tests/components/aosmith/test_diagnostics.py b/tests/components/aosmith/test_diagnostics.py index 9090ef5e7b7..d9fbed513bb 100644 --- a/tests/components/aosmith/test_diagnostics.py +++ b/tests/components/aosmith/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the A. O. Smith integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index 5994a7f4c17..2a786925e70 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -82,13 +82,18 @@ MOCK_MINIMAL_STATUS: Final = OrderedDict( async def async_init_integration( - hass: HomeAssistant, host: str = "test", status: dict[str, str] | None = None + hass: HomeAssistant, + *, + host: str = "test", + status: dict[str, str] | None = None, + entry_id: str = "mocked-config-entry-id", ) -> MockConfigEntry: """Set up the APC UPS Daemon integration in HomeAssistant.""" if status is None: status = MOCK_STATUS entry = MockConfigEntry( + entry_id=entry_id, version=1, domain=DOMAIN, title="APCUPSd", diff --git a/tests/components/apcupsd/snapshots/test_binary_sensor.ambr b/tests/components/apcupsd/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..898525cde9c --- /dev/null +++ b/tests/components/apcupsd/snapshots/test_binary_sensor.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.myups_online_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.myups_online_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': 'Online status', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'online_status', + 'unique_id': 'XXXXXXXXXXXX_statflag', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.myups_online_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Online status', + }), + 'context': , + 'entity_id': 'binary_sensor.myups_online_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr new file mode 100644 index 00000000000..39f28b528fc --- /dev/null +++ b/tests/components/apcupsd/snapshots/test_init.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_async_setup_entry[status0][device_MyUPS_XXXXXXXXXXXX] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '928.a8 .D USB FW:a8', + 'id': , + 'identifiers': set({ + tuple( + 'apcupsd', + 'XXXXXXXXXXXX', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'APC', + 'model': 'Back-UPS ES 600', + 'model_id': None, + 'name': 'MyUPS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.14.14 (31 May 2016) unknown', + 'via_device_id': None, + }) +# --- +# name: test_async_setup_entry[status1][device_APC UPS_XXXX] + 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( + 'apcupsd', + 'XXXX', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'APC', + 'model': None, + 'model_id': None, + 'name': 'APC UPS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_async_setup_entry[status2][device_APC UPS_] + 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( + 'apcupsd', + 'mocked-config-entry-id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'APC', + 'model': None, + 'model_id': None, + 'name': 'APC UPS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_async_setup_entry[status3][device_APC UPS_Blank] + 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( + 'apcupsd', + 'mocked-config-entry-id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'APC', + 'model': None, + 'model_id': None, + 'name': 'APC UPS', + '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/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..9c0b2de4fdc --- /dev/null +++ b/tests/components/apcupsd/snapshots/test_sensor.ambr @@ -0,0 +1,2072 @@ +# serializer version: 1 +# name: test_sensor[sensor.myups_alarm_delay-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.myups_alarm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm delay', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_delay', + 'unique_id': 'XXXXXXXXXXXX_alarmdel', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Alarm delay', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_sensor[sensor.myups_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.myups_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': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'XXXXXXXXXXXX_bcharge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.myups_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'MyUPS Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.myups_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensor[sensor.myups_battery_nominal_voltage-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.myups_battery_nominal_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery nominal voltage', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_nominal_voltage', + 'unique_id': 'XXXXXXXXXXXX_nombattv', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_battery_nominal_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Battery nominal voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_battery_nominal_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_sensor[sensor.myups_battery_replaced-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.myups_battery_replaced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery replaced', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_date', + 'unique_id': 'XXXXXXXXXXXX_battdate', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_battery_replaced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Battery replaced', + }), + 'context': , + 'entity_id': 'sensor.myups_battery_replaced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01', + }) +# --- +# name: test_sensor[sensor.myups_battery_shutdown-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.myups_battery_shutdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery shutdown', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_battery_charge', + 'unique_id': 'XXXXXXXXXXXX_mbattchg', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.myups_battery_shutdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Battery shutdown', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.myups_battery_shutdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor[sensor.myups_battery_timeout-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.myups_battery_timeout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery timeout', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_time', + 'unique_id': 'XXXXXXXXXXXX_maxtime', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_battery_timeout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Battery timeout', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_battery_timeout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.myups_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.myups_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'XXXXXXXXXXXX_battv', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.7', + }) +# --- +# name: test_sensor[sensor.myups_cable_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_cable_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cable type', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cable_type', + 'unique_id': 'XXXXXXXXXXXX_cable', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_cable_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Cable type', + }), + 'context': , + 'entity_id': 'sensor.myups_cable_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'USB Cable', + }) +# --- +# name: test_sensor[sensor.myups_daemon_version-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.myups_daemon_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Daemon version', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'version', + 'unique_id': 'XXXXXXXXXXXX_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_daemon_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Daemon version', + }), + 'context': , + 'entity_id': 'sensor.myups_daemon_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.14.14 (31 May 2016) unknown', + }) +# --- +# name: test_sensor[sensor.myups_date_and_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.myups_date_and_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': 'Date and time', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'date_and_time', + 'unique_id': 'XXXXXXXXXXXX_end apc', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_date_and_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Date and time', + }), + 'context': , + 'entity_id': 'sensor.myups_date_and_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- +# name: test_sensor[sensor.myups_driver-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.myups_driver', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Driver', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'driver', + 'unique_id': 'XXXXXXXXXXXX_driver', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_driver-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Driver', + }), + 'context': , + 'entity_id': 'sensor.myups_driver', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'USB UPS Driver', + }) +# --- +# name: test_sensor[sensor.myups_firmware_version-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.myups_firmware_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Firmware version', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'firmware_version', + 'unique_id': 'XXXXXXXXXXXX_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_firmware_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Firmware version', + }), + 'context': , + 'entity_id': 'sensor.myups_firmware_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '928.a8 .D USB FW:a8', + }) +# --- +# name: test_sensor[sensor.myups_input_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.myups_input_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input voltage', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'line_voltage', + 'unique_id': 'XXXXXXXXXXXX_linev', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_input_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Input voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_input_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '124.0', + }) +# --- +# name: test_sensor[sensor.myups_internal_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.myups_internal_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': 'Internal temperature', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'internal_temperature', + 'unique_id': 'XXXXXXXXXXXX_itemp', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_internal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'MyUPS Internal temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_internal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.6', + }) +# --- +# name: test_sensor[sensor.myups_last_self_test-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.myups_last_self_test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Last self-test', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_self_test', + 'unique_id': 'XXXXXXXXXXXX_laststest', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_last_self_test-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Last self-test', + }), + 'context': , + 'entity_id': 'sensor.myups_last_self_test', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- +# name: test_sensor[sensor.myups_last_transfer-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.myups_last_transfer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Last transfer', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_transfer', + 'unique_id': 'XXXXXXXXXXXX_lastxfer', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_last_transfer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Last transfer', + }), + 'context': , + 'entity_id': 'sensor.myups_last_transfer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Automatic or explicit self test', + }) +# --- +# name: test_sensor[sensor.myups_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.myups_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Load', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_capacity', + 'unique_id': 'XXXXXXXXXXXX_loadpct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.myups_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Load', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.myups_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.0', + }) +# --- +# name: test_sensor[sensor.myups_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_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': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ups_mode', + 'unique_id': 'XXXXXXXXXXXX_upsmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Mode', + }), + 'context': , + 'entity_id': 'sensor.myups_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Stand Alone', + }) +# --- +# name: test_sensor[sensor.myups_model-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.myups_model', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Model', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'model', + 'unique_id': 'XXXXXXXXXXXX_model', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_model-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Model', + }), + 'context': , + 'entity_id': 'sensor.myups_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Back-UPS ES 600', + }) +# --- +# name: test_sensor[sensor.myups_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Name', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ups_name', + 'unique_id': 'XXXXXXXXXXXX_upsname', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Name', + }), + 'context': , + 'entity_id': 'sensor.myups_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'MyUPS', + }) +# --- +# name: test_sensor[sensor.myups_nominal_apparent_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_nominal_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nominal apparent power', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nominal_apparent_power', + 'unique_id': 'XXXXXXXXXXXX_nomapnt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_nominal_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'MyUPS Nominal apparent power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_nominal_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_sensor[sensor.myups_nominal_input_voltage-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.myups_nominal_input_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nominal input voltage', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nominal_input_voltage', + 'unique_id': 'XXXXXXXXXXXX_nominv', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_nominal_input_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Nominal input voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_nominal_input_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_sensor[sensor.myups_nominal_output_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_nominal_output_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nominal output power', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nominal_output_power', + 'unique_id': 'XXXXXXXXXXXX_nompower', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_nominal_output_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'MyUPS Nominal output power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_nominal_output_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '330', + }) +# --- +# name: test_sensor[sensor.myups_output_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.myups_output_current', + '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': 'Output current', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'output_current', + 'unique_id': 'XXXXXXXXXXXX_outcurnt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_output_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'MyUPS Output current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_output_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.88', + }) +# --- +# name: test_sensor[sensor.myups_self_test_interval-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.myups_self_test_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Self-test interval', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'self_test_interval', + 'unique_id': 'XXXXXXXXXXXX_stesti', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_self_test_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Self-test interval', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_self_test_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensor[sensor.myups_self_test_result-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.myups_self_test_result', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Self-test result', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'self_test_result', + 'unique_id': 'XXXXXXXXXXXX_selftest', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_self_test_result-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Self-test result', + }), + 'context': , + 'entity_id': 'sensor.myups_self_test_result', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'NO', + }) +# --- +# name: test_sensor[sensor.myups_sensitivity-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.myups_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity', + 'unique_id': 'XXXXXXXXXXXX_sense', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Sensitivity', + }), + 'context': , + 'entity_id': 'sensor.myups_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Medium', + }) +# --- +# name: test_sensor[sensor.myups_serial_number-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.myups_serial_number', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Serial number', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'serial_number', + 'unique_id': 'XXXXXXXXXXXX_serialno', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_serial_number-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Serial number', + }), + 'context': , + 'entity_id': 'sensor.myups_serial_number', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'XXXXXXXXXXXX', + }) +# --- +# name: test_sensor[sensor.myups_shutdown_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.myups_shutdown_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': 'Shutdown time', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'min_time', + 'unique_id': 'XXXXXXXXXXXX_mintimel', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_shutdown_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Shutdown time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_shutdown_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor[sensor.myups_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.myups_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': 'Status', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'XXXXXXXXXXXX_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Status', + }), + 'context': , + 'entity_id': 'sensor.myups_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ONLINE', + }) +# --- +# name: test_sensor[sensor.myups_status_data-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.myups_status_data', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status data', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'apc_status', + 'unique_id': 'XXXXXXXXXXXX_apc', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_status_data-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Status data', + }), + 'context': , + 'entity_id': 'sensor.myups_status_data', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '001,038,0985', + }) +# --- +# name: test_sensor[sensor.myups_status_date-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.myups_status_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status date', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'date', + 'unique_id': 'XXXXXXXXXXXX_date', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_status_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Status date', + }), + 'context': , + 'entity_id': 'sensor.myups_status_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- +# name: test_sensor[sensor.myups_status_flag-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.myups_status_flag', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status flag', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'online_status', + 'unique_id': 'XXXXXXXXXXXX_statflag', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_status_flag-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Status flag', + }), + 'context': , + 'entity_id': 'sensor.myups_status_flag', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0x05000008', + }) +# --- +# name: test_sensor[sensor.myups_time_left-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.myups_time_left', + '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': 'Time left', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_left', + 'unique_id': 'XXXXXXXXXXXX_timeleft', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'MyUPS Time left', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.0', + }) +# --- +# name: test_sensor[sensor.myups_time_on_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.myups_time_on_battery', + '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': 'Time on battery', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_on_battery', + 'unique_id': 'XXXXXXXXXXXX_tonbatt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_time_on_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'MyUPS Time on battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_time_on_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.myups_total_time_on_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.myups_total_time_on_battery', + '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': 'Total time on battery', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_time_on_battery', + 'unique_id': 'XXXXXXXXXXXX_cumonbatt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_total_time_on_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'MyUPS Total time on battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_total_time_on_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_sensor[sensor.myups_transfer_count-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.myups_transfer_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Transfer count', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_count', + 'unique_id': 'XXXXXXXXXXXX_numxfers', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_transfer_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Transfer count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.myups_transfer_from_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_transfer_from_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Transfer from battery', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_from_battery', + 'unique_id': 'XXXXXXXXXXXX_xoffbatt', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_transfer_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Transfer from battery', + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- +# name: test_sensor[sensor.myups_transfer_high-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.myups_transfer_high', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Transfer high', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_high', + 'unique_id': 'XXXXXXXXXXXX_hitrans', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_transfer_high-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Transfer high', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_high', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '139.0', + }) +# --- +# name: test_sensor[sensor.myups_transfer_low-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.myups_transfer_low', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Transfer low', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_low', + 'unique_id': 'XXXXXXXXXXXX_lotrans', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_transfer_low-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Transfer low', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_low', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '92.0', + }) +# --- +# name: test_sensor[sensor.myups_transfer_to_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_transfer_to_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Transfer to battery', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_to_battery', + 'unique_id': 'XXXXXXXXXXXX_xonbatt', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_transfer_to_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Transfer to battery', + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_to_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- diff --git a/tests/components/apcupsd/test_binary_sensor.py b/tests/components/apcupsd/test_binary_sensor.py index 02351109603..0bf1c00d2f3 100644 --- a/tests/components/apcupsd/test_binary_sensor.py +++ b/tests/components/apcupsd/test_binary_sensor.py @@ -1,27 +1,29 @@ """Test binary sensors of APCUPSd integration.""" -import pytest +from unittest.mock import 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 homeassistant.util import slugify from . import MOCK_STATUS, async_init_integration +from tests.common import snapshot_platform + async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test states of binary sensor.""" - await async_init_integration(hass, status=MOCK_STATUS) - - device_slug, serialno = slugify(MOCK_STATUS["UPSNAME"]), MOCK_STATUS["SERIALNO"] - state = hass.states.get(f"binary_sensor.{device_slug}_online_status") - assert state - assert state.state == "on" - entry = entity_registry.async_get(f"binary_sensor.{device_slug}_online_status") - assert entry - assert entry.unique_id == f"{serialno}_statflag" + """Test states of binary sensors.""" + with patch("homeassistant.components.apcupsd.PLATFORMS", [Platform.BINARY_SENSOR]): + config_entry = await async_init_integration(hass, status=MOCK_STATUS) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_no_binary_sensor(hass: HomeAssistant) -> None: diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 0b8386dbb5a..e635b7d6681 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -1,5 +1,7 @@ """Test APCUPSd config flow setup process.""" +from __future__ import annotations + from copy import copy from unittest.mock import patch @@ -25,7 +27,9 @@ def _patch_setup(): async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: """Test config flow setup with connection error.""" - with patch("aioapcaccess.request_status") as mock_get: + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_get: mock_get.side_effect = OSError() result = await hass.config_entries.flow.async_init( @@ -51,7 +55,9 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: mock_entry.add_to_hass(hass) with ( - patch("aioapcaccess.request_status") as mock_request_status, + patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_request_status, _patch_setup(), ): mock_request_status.return_value = MOCK_STATUS @@ -98,7 +104,10 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: async def test_flow_works(hass: HomeAssistant) -> None: """Test successful creation of config entries via user configuration.""" with ( - patch("aioapcaccess.request_status", return_value=MOCK_STATUS), + patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=MOCK_STATUS, + ), _patch_setup() as mock_setup, ): result = await hass.config_entries.flow.async_init( @@ -111,7 +120,6 @@ async def test_flow_works(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONF_DATA ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_STATUS["UPSNAME"] assert result["data"] == CONF_DATA @@ -139,7 +147,9 @@ async def test_flow_minimal_status( integration will vary. """ with ( - patch("aioapcaccess.request_status") as mock_request_status, + patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_request_status, _patch_setup() as mock_setup, ): status = MOCK_MINIMAL_STATUS | extra_status @@ -153,3 +163,116 @@ async def test_flow_minimal_status( assert result["data"] == CONF_DATA assert result["title"] == expected_title mock_setup.assert_called_once() + + +async def test_reconfigure_flow_works(hass: HomeAssistant) -> None: + """Test successful reconfiguration of an existing entry.""" + mock_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="APCUPSd", + data=CONF_DATA, + unique_id=MOCK_STATUS["SERIALNO"], + source=SOURCE_USER, + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # New configuration data with different host/port. + new_conf_data = {CONF_HOST: "new_host", CONF_PORT: 4321} + + with ( + patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=MOCK_STATUS, + ), + _patch_setup() as mock_setup, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=new_conf_data + ) + await hass.async_block_till_done() + mock_setup.assert_called_once() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check that the entry was updated with the new configuration. + assert mock_entry.data[CONF_HOST] == new_conf_data[CONF_HOST] + assert mock_entry.data[CONF_PORT] == new_conf_data[CONF_PORT] + + +async def test_reconfigure_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test reconfiguration with connection error.""" + mock_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="APCUPSd", + data=CONF_DATA, + unique_id=MOCK_STATUS["SERIALNO"], + source=SOURCE_USER, + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # New configuration data with different host/port. + new_conf_data = {CONF_HOST: "new_host", CONF_PORT: 4321} + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + side_effect=OSError(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=new_conf_data + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("unique_id_before", "unique_id_after"), + [ + (None, MOCK_STATUS["SERIALNO"]), + (MOCK_STATUS["SERIALNO"], "Blank"), + (MOCK_STATUS["SERIALNO"], MOCK_STATUS["SERIALNO"] + "ZZZ"), + ], +) +async def test_reconfigure_flow_wrong_device( + hass: HomeAssistant, unique_id_before: str | None, unique_id_after: str | None +) -> None: + """Test reconfiguration with a different device (wrong serial number).""" + mock_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="APCUPSd", + data=CONF_DATA, + unique_id=unique_id_before, + source=SOURCE_USER, + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # New configuration data with different host/port. + new_conf_data = {CONF_HOST: "new_host", CONF_PORT: 4321} + # Make a copy of the status and modify the serial number if needed. + mock_status = {k: v for k, v in MOCK_STATUS.items() if k != "SERIALNO"} + mock_status["SERIALNO"] = unique_id_after + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=mock_status, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=new_conf_data + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_apcupsd_daemon" diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 9edf4d8282f..e7328603a59 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -5,6 +5,7 @@ from collections import OrderedDict from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.apcupsd.const import DOMAIN from homeassistant.components.apcupsd.coordinator import UPDATE_INTERVAL @@ -12,6 +13,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.util import slugify, utcnow from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration @@ -28,71 +30,31 @@ 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 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. + # 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"}, ], ) -async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> None: - """Test a successful setup entry.""" - await async_init_integration(hass, status=status) - - 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") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "on" - - -@pytest.mark.parametrize( - "status", - [ - # We should not create device entries if SERIALNO is not reported. - MOCK_MINIMAL_STATUS, - # Some models report "Blank" as SERIALNO, but we should treat it as not reported. - MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, - # We should set the device name to be the friendly UPSNAME field if available. - MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX", "UPSNAME": "MyUPS"}, - # Otherwise, we should fall back to default device name --- "APC UPS". - MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX"}, - # We should create all fields of the device entry if they are available. - MOCK_STATUS, - ], -) -async def test_device_entry( - hass: HomeAssistant, status: OrderedDict, device_registry: dr.DeviceRegistry +async def test_async_setup_entry( + hass: HomeAssistant, + status: OrderedDict, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test successful setup of device entries.""" + """Test a successful setup entry.""" config_entry = await async_init_integration(hass, status=status) - - # Verify device info is properly set up. - assert len(device_registry.devices) == 1 - entry = device_registry.async_get_device( - {(DOMAIN, config_entry.unique_id or config_entry.entry_id)} + device_entry = device_registry.async_get_device( + identifiers={(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 = { - "UPSNAME": entry.name, - "MODEL": entry.model, - "VERSION": entry.sw_version, - "FIRMWARE": entry.hw_version, - } + name = f"device_{device_entry.name}_{status.get('SERIALNO', '')}" + assert device_entry == snapshot(name=name) - for field, entry_value in fields.items(): - if field in status: - assert entry_value == status[field] - # Even if UPSNAME is not available, we must fall back to default "APC UPS". - elif field == "UPSNAME": - assert entry_value == "APC UPS" - else: - assert not entry_value - - assert entry.manufacturer == "APC" + platforms = async_get_platforms(hass, DOMAIN) + assert len(platforms) > 0 + assert all(len(p.entities) > 0 for p in platforms) async def test_multiple_integrations(hass: HomeAssistant) -> None: @@ -101,8 +63,12 @@ async def test_multiple_integrations(hass: HomeAssistant) -> None: status1 = MOCK_STATUS | {"LOADPCT": "15.0 Percent", "SERIALNO": "XXXXX1"} status2 = MOCK_STATUS | {"LOADPCT": "16.0 Percent", "SERIALNO": "XXXXX2"} entries = ( - await async_init_integration(hass, host="test1", status=status1), - await async_init_integration(hass, host="test2", status=status2), + await async_init_integration( + hass, host="test1", status=status1, entry_id="entry-id-1" + ), + await async_init_integration( + hass, host="test2", status=status2, entry_id="entry-id-2" + ), ) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 @@ -121,8 +87,12 @@ async def test_multiple_integrations_different_devices(hass: HomeAssistant) -> N status1 = MOCK_STATUS | {"SERIALNO": "XXXXX1", "UPSNAME": "MyUPS1"} status2 = MOCK_STATUS | {"SERIALNO": "XXXXX2", "UPSNAME": "MyUPS2"} entries = ( - await async_init_integration(hass, host="test1", status=status1), - await async_init_integration(hass, host="test2", status=status2), + await async_init_integration( + hass, host="test1", status=status1, entry_id="entry-id-1" + ), + await async_init_integration( + hass, host="test2", status=status2, entry_id="entry-id-2" + ), ) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 @@ -159,8 +129,12 @@ async def test_unload_remove_entry(hass: HomeAssistant) -> None: """Test successful unload and removal of an entry.""" # Load two integrations from two mock hosts. entries = ( - await async_init_integration(hass, host="test1", status=MOCK_STATUS), - await async_init_integration(hass, host="test2", status=MOCK_MINIMAL_STATUS), + await async_init_integration( + hass, host="test1", status=MOCK_STATUS, entry_id="entry-id-1" + ), + await async_init_integration( + hass, host="test2", status=MOCK_MINIMAL_STATUS, entry_id="entry-id-2" + ), ) # Assert they are loaded. diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index f36421c4183..4da17b1c128 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -3,22 +3,15 @@ from datetime import timedelta from unittest.mock import patch +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.apcupsd.coordinator import REQUEST_REFRESH_COOLDOWN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, - UnitOfElectricPotential, - UnitOfPower, - UnitOfTime, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -28,118 +21,19 @@ from homeassistant.util.dt import utcnow from . import MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform -async def test_sensor(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: - """Test states of sensor.""" - await async_init_integration(hass, status=MOCK_STATUS) - device_slug, serialno = slugify(MOCK_STATUS["UPSNAME"]), MOCK_STATUS["SERIALNO"] - - # Test a representative string sensor. - state = hass.states.get(f"sensor.{device_slug}_mode") - assert state - assert state.state == "Stand Alone" - entry = entity_registry.async_get(f"sensor.{device_slug}_mode") - assert entry - assert entry.unique_id == f"{serialno}_upsmode" - - # Test two representative voltage sensors. - state = hass.states.get(f"sensor.{device_slug}_input_voltage") - assert state - assert state.state == "124.0" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - entry = entity_registry.async_get(f"sensor.{device_slug}_input_voltage") - assert entry - assert entry.unique_id == f"{serialno}_linev" - - state = hass.states.get(f"sensor.{device_slug}_battery_voltage") - assert state - assert state.state == "13.7" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - entry = entity_registry.async_get(f"sensor.{device_slug}_battery_voltage") - assert entry - assert entry.unique_id == f"{serialno}_battv" - - # Test a representative time sensor. - state = hass.states.get(f"sensor.{device_slug}_self_test_interval") - assert state - assert state.state == "7" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.DAYS - entry = entity_registry.async_get(f"sensor.{device_slug}_self_test_interval") - assert entry - assert entry.unique_id == f"{serialno}_stesti" - - # Test a representative percentage sensor. - state = hass.states.get(f"sensor.{device_slug}_load") - assert state - assert state.state == "14.0" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = entity_registry.async_get(f"sensor.{device_slug}_load") - assert entry - assert entry.unique_id == f"{serialno}_loadpct" - - # Test a representative wattage sensor. - state = hass.states.get(f"sensor.{device_slug}_nominal_output_power") - assert state - assert state.state == "330" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - entry = entity_registry.async_get(f"sensor.{device_slug}_nominal_output_power") - assert entry - assert entry.unique_id == f"{serialno}_nompower" - - -async def test_sensor_name(hass: HomeAssistant) -> None: - """Test if sensor name follows the recommended entity naming scheme. - - See https://developers.home-assistant.io/docs/core/entity/#entity-naming for more details. - """ - await async_init_integration(hass, status=MOCK_STATUS) - - all_states = hass.states.async_all() - assert len(all_states) != 0 - - device_name = MOCK_STATUS["UPSNAME"] - for state in all_states: - # Friendly name must start with the device name. - friendly_name = state.name - assert friendly_name.startswith(device_name) - - # Entity names should start with a capital letter, the rest of the words are lower case. - entity_name = friendly_name.removeprefix(device_name).strip() - assert entity_name == entity_name.capitalize() - - -async def test_sensor_disabled( - hass: HomeAssistant, entity_registry: er.EntityRegistry +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test sensor disabled by default.""" - await async_init_integration(hass) - - device_slug, serialno = slugify(MOCK_STATUS["UPSNAME"]), MOCK_STATUS["SERIALNO"] - # Test a representative integration-disabled sensor. - entry = entity_registry.async_get(f"sensor.{device_slug}_model") - assert entry.disabled - assert entry.unique_id == f"{serialno}_model" - 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 + """Test states of sensor.""" + with patch("homeassistant.components.apcupsd.PLATFORMS", [Platform.SENSOR]): + config_entry = await async_init_integration(hass, status=MOCK_STATUS) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_state_update(hass: HomeAssistant) -> None: @@ -241,7 +135,7 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: async def test_sensor_unknown(hass: HomeAssistant) -> None: - """Test if our integration can properly certain sensors as unknown when it becomes so.""" + """Test if our integration can properly mark certain sensors as unknown when it becomes so.""" await async_init_integration(hass, status=MOCK_MINIMAL_STATUS) ups_mode_id = "sensor.apc_ups_mode" diff --git a/tests/components/apsystems/snapshots/test_binary_sensor.ambr b/tests/components/apsystems/snapshots/test_binary_sensor.ambr index d2e73347c83..d8088288461 100644 --- a/tests/components/apsystems/snapshots/test_binary_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'DC 1 short circuit error status', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dc_1_short_circuit_error_status', 'unique_id': 'MY_SERIAL_NUMBER_dc_1_short_circuit_error_status', @@ -75,6 +76,7 @@ 'original_name': 'DC 2 short circuit error status', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dc_2_short_circuit_error_status', 'unique_id': 'MY_SERIAL_NUMBER_dc_2_short_circuit_error_status', @@ -123,6 +125,7 @@ 'original_name': 'Off-grid status', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'off_grid_status', 'unique_id': 'MY_SERIAL_NUMBER_off_grid_status', @@ -171,6 +174,7 @@ 'original_name': 'Output fault status', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_fault_status', 'unique_id': 'MY_SERIAL_NUMBER_output_fault_status', diff --git a/tests/components/apsystems/snapshots/test_number.ambr b/tests/components/apsystems/snapshots/test_number.ambr index 21141de7d64..7d02e6e16c4 100644 --- a/tests/components/apsystems/snapshots/test_number.ambr +++ b/tests/components/apsystems/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Max output', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_output', 'unique_id': 'MY_SERIAL_NUMBER_output_limit', diff --git a/tests/components/apsystems/snapshots/test_sensor.ambr b/tests/components/apsystems/snapshots/test_sensor.ambr index 251a8d8428c..f163c4db840 100644 --- a/tests/components/apsystems/snapshots/test_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Lifetime production of P1', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production_p1', 'unique_id': 'MY_SERIAL_NUMBER_lifetime_production_p1', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Lifetime production of P2', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production_p2', 'unique_id': 'MY_SERIAL_NUMBER_lifetime_production_p2', @@ -127,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power of P1', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_power_p1', 'unique_id': 'MY_SERIAL_NUMBER_total_power_p1', @@ -179,12 +191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power of P2', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_power_p2', 'unique_id': 'MY_SERIAL_NUMBER_total_power_p2', @@ -231,12 +247,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Production of today', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'today_production', 'unique_id': 'MY_SERIAL_NUMBER_today_production', @@ -283,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Production of today from P1', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'today_production_p1', 'unique_id': 'MY_SERIAL_NUMBER_today_production_p1', @@ -335,12 +359,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Production of today from P2', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'today_production_p2', 'unique_id': 'MY_SERIAL_NUMBER_today_production_p2', @@ -387,12 +415,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total lifetime production', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': 'MY_SERIAL_NUMBER_lifetime_production', @@ -439,12 +471,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total power', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_power', 'unique_id': 'MY_SERIAL_NUMBER_total_power', diff --git a/tests/components/apsystems/snapshots/test_switch.ambr b/tests/components/apsystems/snapshots/test_switch.ambr index a9f74ee5517..2b3ccbab6c4 100644 --- a/tests/components/apsystems/snapshots/test_switch.ambr +++ b/tests/components/apsystems/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Inverter status', 'platform': 'apsystems', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'inverter_status', 'unique_id': 'MY_SERIAL_NUMBER_inverter_status', diff --git a/tests/components/apsystems/test_binary_sensor.py b/tests/components/apsystems/test_binary_sensor.py index 0c6fbffc93c..88e482e3eaa 100644 --- a/tests/components/apsystems/test_binary_sensor.py +++ b/tests/components/apsystems/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/apsystems/test_number.py b/tests/components/apsystems/test_number.py index 912759b4a17..6cf054148bf 100644 --- a/tests/components/apsystems/test_number.py +++ b/tests/components/apsystems/test_number.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/apsystems/test_sensor.py b/tests/components/apsystems/test_sensor.py index 810ad3e7bdf..9a87e7ecf18 100644 --- a/tests/components/apsystems/test_sensor.py +++ b/tests/components/apsystems/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/apsystems/test_switch.py b/tests/components/apsystems/test_switch.py index afd889fe958..290cece126d 100644 --- a/tests/components/apsystems/test_switch.py +++ b/tests/components/apsystems/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/aquacell/snapshots/test_sensor.ambr b/tests/components/aquacell/snapshots/test_sensor.ambr index eeac14c000d..c24a7f43cfe 100644 --- a/tests/components/aquacell/snapshots/test_sensor.ambr +++ b/tests/components/aquacell/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'aquacell', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'DSN-battery', @@ -48,6 +49,55 @@ 'state': '40', }) # --- +# name: test_sensors[sensor.aquacell_name_last_update-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.aquacell_name_last_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_update', + 'unique_id': 'DSN-last_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aquacell_name_last_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'AquaCell name Last update', + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_last_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-05-10T07:44:30+00:00', + }) +# --- # name: test_sensors[sensor.aquacell_name_salt_left_side_percentage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -78,6 +128,7 @@ 'original_name': 'Salt left side percentage', 'platform': 'aquacell', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salt_left_side_percentage', 'unique_id': 'DSN-salt_left_side_percentage', @@ -121,12 +172,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Salt left side time remaining', 'platform': 'aquacell', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salt_left_side_time_remaining', 'unique_id': 'DSN-salt_left_side_time_remaining', @@ -178,6 +233,7 @@ 'original_name': 'Salt right side percentage', 'platform': 'aquacell', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salt_right_side_percentage', 'unique_id': 'DSN-salt_right_side_percentage', @@ -221,12 +277,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Salt right side time remaining', 'platform': 'aquacell', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salt_right_side_time_remaining', 'unique_id': 'DSN-salt_right_side_time_remaining', @@ -282,6 +342,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'aquacell', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wi_fi_strength', 'unique_id': 'DSN-wi_fi_strength', diff --git a/tests/components/aquacell/test_sensor.py b/tests/components/aquacell/test_sensor.py index 0c59dcc40e9..007040d9c79 100644 --- a/tests/components/aquacell/test_sensor.py +++ b/tests/components/aquacell/test_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/arve/snapshots/test_sensor.ambr b/tests/components/arve/snapshots/test_sensor.ambr index ed2494c3197..eb51aa8c1f2 100644 --- a/tests/components/arve/snapshots/test_sensor.ambr +++ b/tests/components/arve/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Air quality index', 'platform': 'arve', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-serial-number_AQI', @@ -65,6 +66,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'arve', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-serial-number_CO2', @@ -101,6 +103,7 @@ 'original_name': 'Humidity', 'platform': 'arve', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-serial-number_Humidity', @@ -137,6 +140,7 @@ 'original_name': 'PM10', 'platform': 'arve', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-serial-number_PM10', @@ -173,6 +177,7 @@ 'original_name': 'PM2.5', 'platform': 'arve', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-serial-number_PM25', @@ -203,12 +208,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'arve', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-serial-number_Temperature', @@ -245,6 +254,7 @@ 'original_name': 'Total volatile organic compounds', 'platform': 'arve', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tvoc', 'unique_id': 'test-serial-number_TVOC', diff --git a/tests/components/arve/test_sensor.py b/tests/components/arve/test_sensor.py index 541820fd7b6..77711632c56 100644 --- a/tests/components/arve/test_sensor.py +++ b/tests/components/arve/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/assist_pipeline/__init__.py b/tests/components/assist_pipeline/__init__.py index dd0f80e52ad..cc11fcc6c82 100644 --- a/tests/components/assist_pipeline/__init__.py +++ b/tests/components/assist_pipeline/__init__.py @@ -1,5 +1,10 @@ """Tests for the Voice Assistant integration.""" +from dataclasses import asdict +from unittest.mock import ANY + +from homeassistant.components import assist_pipeline + MANY_LANGUAGES = [ "ar", "bg", @@ -54,3 +59,16 @@ MANY_LANGUAGES = [ "zh-hk", "zh-tw", ] + + +def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: + """Process events to remove dynamic values.""" + processed = [] + for event in events: + as_dict = asdict(event) + as_dict.pop("timestamp") + if as_dict["type"] == assist_pipeline.PipelineEventType.RUN_START: + as_dict["data"]["pipeline"] = ANY + processed.append(as_dict) + + return processed diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index a0549f27f05..e20452a1f93 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -37,7 +37,7 @@ from tests.common import ( mock_platform, ) from tests.components.stt.common import MockSTTProvider, MockSTTProviderEntity -from tests.components.tts.common import MockTTSProvider +from tests.components.tts.common import MockTTSEntity, MockTTSProvider _TRANSCRIPT = "test transcript" @@ -68,6 +68,15 @@ async def mock_tts_provider() -> MockTTSProvider: return provider +@pytest.fixture +def mock_tts_entity() -> MockTTSEntity: + """Test TTS entity.""" + entity = MockTTSEntity("en") + entity._attr_unique_id = "test_tts" + entity._attr_supported_languages = ["en-US"] + return entity + + @pytest.fixture async def mock_stt_provider() -> MockSTTProvider: """Mock STT provider.""" @@ -198,6 +207,7 @@ async def init_supporting_components( mock_stt_provider: MockSTTProvider, mock_stt_provider_entity: MockSTTProviderEntity, mock_tts_provider: MockTTSProvider, + mock_tts_entity: MockTTSEntity, mock_wake_word_provider_entity: MockWakeWordEntity, mock_wake_word_provider_entity2: MockWakeWordEntity2, config_flow_fixture, @@ -209,7 +219,7 @@ async def init_supporting_components( ) -> bool: """Set up test config entry.""" await hass.config_entries.async_forward_entry_setups( - config_entry, [Platform.STT, Platform.WAKE_WORD] + config_entry, [Platform.STT, Platform.TTS, Platform.WAKE_WORD] ) return True @@ -230,6 +240,14 @@ async def init_supporting_components( """Set up test stt platform via config entry.""" async_add_entities([mock_stt_provider_entity]) + async def async_setup_entry_tts_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test tts platform via config entry.""" + async_add_entities([mock_tts_entity]) + async def async_setup_entry_wake_word_platform( hass: HomeAssistant, config_entry: ConfigEntry, @@ -253,6 +271,7 @@ async def init_supporting_components( "test.tts", MockTTSPlatform( async_get_engine=AsyncMock(return_value=mock_tts_provider), + async_setup_entry=async_setup_entry_tts_platform, ), ) mock_platform( diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f772f877d3a..4ae4b5dce4c 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -8,6 +8,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), @@ -74,17 +75,17 @@ }), dict({ 'data': dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }), 'type': , }), dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': 'media-source://tts/-stream-/test_token.mp3', 'mime_type': 'audio/mpeg', 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', @@ -107,6 +108,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), @@ -183,7 +185,7 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", + 'media_id': 'media-source://tts/-stream-/test_token.mp3', 'mime_type': 'audio/mpeg', 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', @@ -206,6 +208,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), @@ -282,7 +285,7 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", + 'media_id': 'media-source://tts/-stream-/test_token.mp3', 'mime_type': 'audio/mpeg', 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', @@ -305,6 +308,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), @@ -395,17 +399,17 @@ }), dict({ 'data': dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }), 'type': , }), dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': 'media-source://tts/-stream-/test_token.mp3', 'mime_type': 'audio/mpeg', 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', @@ -428,6 +432,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -461,204 +466,3 @@ }), ]) # --- -# name: test_pipeline_language_used_instead_of_conversation_language - list([ - dict({ - 'data': dict({ - 'conversation_id': 'mock-ulid', - 'language': 'en', - 'pipeline': , - }), - 'type': , - }), - dict({ - 'data': dict({ - 'conversation_id': 'mock-ulid', - 'device_id': None, - 'engine': 'conversation.home_assistant', - 'intent_input': 'test input', - 'language': 'en', - 'prefer_local_intents': False, - }), - 'type': , - }), - dict({ - 'data': dict({ - 'intent_output': dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - }), - }), - }), - 'processed_locally': True, - }), - 'type': , - }), - dict({ - 'data': None, - 'type': , - }), - ]) -# --- -# name: test_stt_language_used_instead_of_conversation_language - list([ - dict({ - 'data': dict({ - 'conversation_id': 'mock-ulid', - 'language': 'en', - 'pipeline': , - }), - 'type': , - }), - dict({ - 'data': dict({ - 'conversation_id': 'mock-ulid', - 'device_id': None, - 'engine': 'conversation.home_assistant', - 'intent_input': 'test input', - 'language': 'en-US', - 'prefer_local_intents': False, - }), - 'type': , - }), - dict({ - 'data': dict({ - 'intent_output': dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - }), - }), - }), - 'processed_locally': True, - }), - 'type': , - }), - dict({ - 'data': None, - 'type': , - }), - ]) -# --- -# name: test_tts_language_used_instead_of_conversation_language - list([ - dict({ - 'data': dict({ - 'conversation_id': 'mock-ulid', - 'language': 'en', - 'pipeline': , - }), - 'type': , - }), - dict({ - 'data': dict({ - 'conversation_id': 'mock-ulid', - 'device_id': None, - 'engine': 'conversation.home_assistant', - 'intent_input': 'test input', - 'language': 'en-us', - 'prefer_local_intents': False, - }), - 'type': , - }), - dict({ - 'data': dict({ - 'intent_output': dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - }), - }), - }), - 'processed_locally': True, - }), - 'type': , - }), - dict({ - 'data': None, - 'type': , - }), - ]) -# --- -# name: test_wake_word_detection_aborted - list([ - dict({ - 'data': dict({ - 'conversation_id': 'mock-ulid', - 'language': 'en', - 'pipeline': , - 'tts_output': dict({ - 'mime_type': 'audio/mpeg', - 'token': 'mocked-token.mp3', - 'url': '/api/tts_proxy/mocked-token.mp3', - }), - }), - 'type': , - }), - dict({ - 'data': dict({ - 'entity_id': 'wake_word.test', - 'metadata': dict({ - 'bit_rate': , - 'channel': , - 'codec': , - 'format': , - 'sample_rate': , - }), - 'timeout': 0, - }), - 'type': , - }), - dict({ - 'data': dict({ - 'code': 'wake_word_detection_aborted', - 'message': '', - }), - 'type': , - }), - dict({ - 'data': None, - 'type': , - }), - ]) -# --- diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr new file mode 100644 index 00000000000..8431e32ed87 --- /dev/null +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -0,0 +1,807 @@ +# serializer version: 1 +# name: test_chat_log_tts_streaming[to_stream_deltas0-0-] + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'stream_response': True, + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'test-agent', + 'intent_input': 'Set a timer', + 'language': 'en', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'role': 'assistant', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'hello,', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': ' ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'how', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': ' ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'are', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': ' ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'you', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': '?', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': True, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'hello, how are you?', + }), + }), + }), + }), + 'processed_locally': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'tts.test', + 'language': 'en_US', + 'tts_input': 'hello, how are you?', + 'voice': None, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'tts_output': dict({ + 'media_id': 'media-source://tts/-stream-/mocked-token.mp3', + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_chat_log_tts_streaming[to_stream_deltas1-3-hello, how are you? I'm doing well, thank you. What about you?!] + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'stream_response': True, + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'test-agent', + 'intent_input': 'Set a timer', + 'language': 'en', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'role': 'assistant', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'hello, ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'how ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'are ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'you', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': '? ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': "I'm ", + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'doing ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'well', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': ', ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'thank ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'you', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': '. ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'What ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'about ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'you', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': '?', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': '!', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "hello, how are you? I'm doing well, thank you. What about you?!", + }), + }), + }), + }), + 'processed_locally': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'tts.test', + 'language': 'en_US', + 'tts_input': "hello, how are you? I'm doing well, thank you. What about you?!", + 'voice': None, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'tts_output': dict({ + 'media_id': 'media-source://tts/-stream-/mocked-token.mp3', + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_chat_log_tts_streaming[to_stream_deltas2-8-hello, how are you? I'm doing well, thank you.] + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'stream_response': True, + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'test-agent', + 'intent_input': 'Set a timer', + 'language': 'en', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'role': 'assistant', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'hello, ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'how ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'are ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'you', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': '? ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'tool_calls': list([ + dict({ + 'id': 'test_tool_id', + 'tool_args': dict({ + }), + 'tool_name': 'test_tool', + }), + ]), + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'agent_id': 'test-agent', + 'role': 'tool_result', + 'tool_call_id': 'test_tool_id', + 'tool_name': 'test_tool', + 'tool_result': 'Test response', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'role': 'assistant', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': "I'm ", + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'doing ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'well', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': ', ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'thank ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'you', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': '.', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "I'm doing well, thank you.", + }), + }), + }), + }), + 'processed_locally': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'tts.test', + 'language': 'en_US', + 'tts_input': "I'm doing well, thank you.", + 'voice': None, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'tts_output': dict({ + 'media_id': 'media-source://tts/-stream-/mocked-token.mp3', + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_pipeline_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_stt_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-US', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_tts_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-us', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_wake_word_detection_aborted + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'stream_response': False, + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'sample_rate': , + }), + 'timeout': 0, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'code': 'wake_word_detection_aborted', + 'message': '', + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 57ae0095236..4f29fd79568 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -10,6 +10,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), @@ -71,16 +72,16 @@ # --- # name: test_audio_pipeline.5 dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }) # --- # name: test_audio_pipeline.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': 'media-source://tts/-stream-/test_token.mp3', 'mime_type': 'audio/mpeg', 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', @@ -101,6 +102,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), @@ -162,16 +164,16 @@ # --- # name: test_audio_pipeline_debug.5 dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }) # --- # name: test_audio_pipeline_debug.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': 'media-source://tts/-stream-/test_token.mp3', 'mime_type': 'audio/mpeg', 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', @@ -204,6 +206,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), @@ -265,16 +268,16 @@ # --- # name: test_audio_pipeline_with_enhancements.5 dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }) # --- # name: test_audio_pipeline_with_enhancements.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': 'media-source://tts/-stream-/test_token.mp3', 'mime_type': 'audio/mpeg', 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', @@ -295,6 +298,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), @@ -378,16 +382,16 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout.7 dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.8 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': 'media-source://tts/-stream-/test_token.mp3', 'mime_type': 'audio/mpeg', 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', @@ -408,6 +412,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), @@ -616,6 +621,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -670,6 +676,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -686,6 +693,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -702,6 +710,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -718,6 +727,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -734,6 +744,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -868,6 +879,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -884,6 +896,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -941,6 +954,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -957,6 +971,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -1017,6 +1032,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), @@ -1033,6 +1049,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'stream_response': False, 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 0e04d1f0cd2..0294f9953db 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -2,44 +2,35 @@ import asyncio from collections.abc import Generator -from dataclasses import asdict import itertools as it from pathlib import Path import tempfile -from unittest.mock import ANY, Mock, patch +from unittest.mock import Mock, patch import wave import hass_nabucasa import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import ( - assist_pipeline, - conversation, - media_source, - stt, - tts, -) +from homeassistant.components import assist_pipeline, stt from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, CONF_DEBUG_RECORDING_DIR, DOMAIN, ) -from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import chat_session, intent from homeassistant.setup import async_setup_component +from . import process_events from .conftest import ( BYTES_ONE_SECOND, MockSTTProvider, MockSTTProviderEntity, - MockTTSProvider, MockWakeWordEntity, make_10ms_chunk, ) -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) @@ -58,19 +49,6 @@ def mock_tts_token() -> Generator[None]: yield -def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: - """Process events to remove dynamic values.""" - processed = [] - for event in events: - as_dict = asdict(event) - as_dict.pop("timestamp") - if as_dict["type"] == assist_pipeline.PipelineEventType.RUN_START: - as_dict["data"]["pipeline"] = ANY - processed.append(as_dict) - - return processed - - async def test_pipeline_from_audio_stream_auto( hass: HomeAssistant, mock_stt_provider_entity: MockSTTProviderEntity, @@ -677,823 +655,6 @@ async def test_pipeline_saved_audio_empty_queue( ) -async def test_wake_word_detection_aborted( - hass: HomeAssistant, - mock_stt_provider: MockSTTProvider, - mock_wake_word_provider_entity: MockWakeWordEntity, - init_components, - pipeline_data: assist_pipeline.pipeline.PipelineData, - mock_chat_session: chat_session.ChatSession, - snapshot: SnapshotAssertion, -) -> None: - """Test creating a pipeline from an audio stream with wake word.""" - - events: list[assist_pipeline.PipelineEvent] = [] - - async def audio_data(): - yield make_10ms_chunk(b"silence!") - yield make_10ms_chunk(b"wake word!") - yield make_10ms_chunk(b"part1") - yield make_10ms_chunk(b"part2") - yield b"" - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - session=mock_chat_session, - device_id=None, - stt_metadata=stt.SpeechMetadata( - language="", - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=audio_data(), - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.WAKE_WORD, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=events.append, - tts_audio_output=None, - wake_word_settings=assist_pipeline.WakeWordSettings( - audio_seconds_to_buffer=1.5 - ), - audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), - ), - ) - await pipeline_input.validate() - - updates = pipeline.to_json() - updates.pop("id") - await pipeline_store.async_update_item( - pipeline_id, - updates, - ) - await pipeline_input.execute() - - assert process_events(events) == snapshot - - -def test_pipeline_run_equality(hass: HomeAssistant, init_components) -> None: - """Test that pipeline run equality uses unique id.""" - - def event_callback(event): - pass - - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass) - run_1 = assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.STT, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=event_callback, - ) - run_2 = assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.STT, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=event_callback, - ) - - assert run_1 == run_1 # noqa: PLR0124 - assert run_1 != run_2 - assert run_1 != 1234 - - -async def test_tts_audio_output( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_tts_provider: MockTTSProvider, - init_components, - pipeline_data: assist_pipeline.pipeline.PipelineData, - mock_chat_session: chat_session.ChatSession, - snapshot: SnapshotAssertion, -) -> None: - """Test using tts_audio_output with wav sets options correctly.""" - client = await hass_client() - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - events: list[assist_pipeline.PipelineEvent] = [] - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - tts_input="This is a test.", - session=mock_chat_session, - device_id=None, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.TTS, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=events.append, - tts_audio_output="wav", - ), - ) - await pipeline_input.validate() - - # Verify TTS audio settings - assert pipeline_input.run.tts_stream.options is not None - assert pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" - assert ( - pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) - == 16000 - ) - assert ( - pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) - == 1 - ) - - with patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio: - await pipeline_input.execute() - - for event in events: - if event.type == assist_pipeline.PipelineEventType.TTS_END: - # We must fetch the media URL to trigger the TTS - assert event.data - await client.get(event.data["tts_output"]["url"]) - - # Ensure that no unsupported options were passed in - assert mock_get_tts_audio.called - options = mock_get_tts_audio.call_args_list[0].kwargs["options"] - extra_options = set(options).difference(mock_tts_provider.supported_options) - assert len(extra_options) == 0, extra_options - - -async def test_tts_wav_preferred_format( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_tts_provider: MockTTSProvider, - init_components, - mock_chat_session: chat_session.ChatSession, - pipeline_data: assist_pipeline.pipeline.PipelineData, -) -> None: - """Test that preferred format options are given to the TTS system if supported.""" - client = await hass_client() - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - events: list[assist_pipeline.PipelineEvent] = [] - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - tts_input="This is a test.", - session=mock_chat_session, - device_id=None, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.TTS, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=events.append, - tts_audio_output="wav", - ), - ) - await pipeline_input.validate() - - # Make the TTS provider support preferred format options - supported_options = list(mock_tts_provider.supported_options or []) - supported_options.extend( - [ - tts.ATTR_PREFERRED_FORMAT, - tts.ATTR_PREFERRED_SAMPLE_RATE, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS, - tts.ATTR_PREFERRED_SAMPLE_BYTES, - ] - ) - - with ( - patch.object(mock_tts_provider, "_supported_options", supported_options), - patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio, - ): - await pipeline_input.execute() - - for event in events: - if event.type == assist_pipeline.PipelineEventType.TTS_END: - # We must fetch the media URL to trigger the TTS - assert event.data - await client.get(event.data["tts_output"]["url"]) - - assert mock_get_tts_audio.called - options = mock_get_tts_audio.call_args_list[0].kwargs["options"] - - # We should have received preferred format options in get_tts_audio - assert options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 16000 - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 1 - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 - - -async def test_tts_dict_preferred_format( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_tts_provider: MockTTSProvider, - init_components, - mock_chat_session: chat_session.ChatSession, - pipeline_data: assist_pipeline.pipeline.PipelineData, -) -> None: - """Test that preferred format options are given to the TTS system if supported.""" - client = await hass_client() - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - events: list[assist_pipeline.PipelineEvent] = [] - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - tts_input="This is a test.", - session=mock_chat_session, - device_id=None, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.TTS, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=events.append, - tts_audio_output={ - tts.ATTR_PREFERRED_FORMAT: "flac", - tts.ATTR_PREFERRED_SAMPLE_RATE: 48000, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, - tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, - }, - ), - ) - await pipeline_input.validate() - - # Make the TTS provider support preferred format options - supported_options = list(mock_tts_provider.supported_options or []) - supported_options.extend( - [ - tts.ATTR_PREFERRED_FORMAT, - tts.ATTR_PREFERRED_SAMPLE_RATE, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS, - tts.ATTR_PREFERRED_SAMPLE_BYTES, - ] - ) - - with ( - patch.object(mock_tts_provider, "_supported_options", supported_options), - patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio, - ): - await pipeline_input.execute() - - for event in events: - if event.type == assist_pipeline.PipelineEventType.TTS_END: - # We must fetch the media URL to trigger the TTS - assert event.data - await client.get(event.data["tts_output"]["url"]) - - assert mock_get_tts_audio.called - options = mock_get_tts_audio.call_args_list[0].kwargs["options"] - - # We should have received preferred format options in get_tts_audio - assert options.get(tts.ATTR_PREFERRED_FORMAT) == "flac" - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 48000 - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 2 - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 - - -async def test_sentence_trigger_overrides_conversation_agent( - hass: HomeAssistant, - init_components, - mock_chat_session: chat_session.ChatSession, - pipeline_data: assist_pipeline.pipeline.PipelineData, -) -> None: - """Test that sentence triggers are checked before a non-default conversation agent.""" - assert await async_setup_component( - hass, - "automation", - { - "automation": { - "trigger": { - "platform": "conversation", - "command": [ - "test trigger sentence", - ], - }, - "action": { - "set_conversation_response": "test trigger response", - }, - } - }, - ) - - events: list[assist_pipeline.PipelineEvent] = [] - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - intent_input="test trigger sentence", - session=mock_chat_session, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.INTENT, - end_stage=assist_pipeline.PipelineStage.INTENT, - event_callback=events.append, - intent_agent="test-agent", # not the default agent - ), - ) - - # Ensure prepare succeeds - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", - return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), - ): - await pipeline_input.validate() - - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" - ) as mock_async_converse: - await pipeline_input.execute() - - # Sentence trigger should have been handled - mock_async_converse.assert_not_called() - - # Verify sentence trigger response - intent_end_event = next( - ( - e - for e in events - if e.type == assist_pipeline.PipelineEventType.INTENT_END - ), - None, - ) - assert (intent_end_event is not None) and intent_end_event.data - assert ( - intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ - "speech" - ] - == "test trigger response" - ) - - -async def test_prefer_local_intents( - hass: HomeAssistant, - init_components, - mock_chat_session: chat_session.ChatSession, - pipeline_data: assist_pipeline.pipeline.PipelineData, -) -> None: - """Test that the default agent is checked first when local intents are preferred.""" - events: list[assist_pipeline.PipelineEvent] = [] - - # Reuse custom sentences in test config - class OrderBeerIntentHandler(intent.IntentHandler): - intent_type = "OrderBeer" - - async def async_handle( - self, intent_obj: intent.Intent - ) -> intent.IntentResponse: - response = intent_obj.create_response() - response.async_set_speech("Order confirmed") - return response - - handler = OrderBeerIntentHandler() - intent.async_register(hass, handler) - - # Fake a test agent and prefer local intents - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - await assist_pipeline.pipeline.async_update_pipeline( - hass, pipeline, conversation_engine="test-agent", prefer_local_intents=True - ) - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - intent_input="I'd like to order a stout please", - session=mock_chat_session, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.INTENT, - end_stage=assist_pipeline.PipelineStage.INTENT, - event_callback=events.append, - ), - ) - - # Ensure prepare succeeds - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", - return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), - ): - await pipeline_input.validate() - - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" - ) as mock_async_converse: - await pipeline_input.execute() - - # Test agent should not have been called - mock_async_converse.assert_not_called() - - # Verify local intent response - intent_end_event = next( - ( - e - for e in events - if e.type == assist_pipeline.PipelineEventType.INTENT_END - ), - None, - ) - assert (intent_end_event is not None) and intent_end_event.data - assert ( - intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ - "speech" - ] - == "Order confirmed" - ) - - -async def test_intent_continue_conversation( - hass: HomeAssistant, - init_components, - mock_chat_session: chat_session.ChatSession, - pipeline_data: assist_pipeline.pipeline.PipelineData, -) -> None: - """Test that a conversation agent flagging continue conversation gets response.""" - events: list[assist_pipeline.PipelineEvent] = [] - - # Fake a test agent and prefer local intents - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - await assist_pipeline.pipeline.async_update_pipeline( - hass, pipeline, conversation_engine="test-agent" - ) - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - intent_input="Set a timer", - session=mock_chat_session, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.INTENT, - end_stage=assist_pipeline.PipelineStage.INTENT, - event_callback=events.append, - ), - ) - - # Ensure prepare succeeds - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", - return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), - ): - await pipeline_input.validate() - - response = intent.IntentResponse("en") - response.async_set_speech("For how long?") - - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", - return_value=conversation.ConversationResult( - response=response, - conversation_id=mock_chat_session.conversation_id, - continue_conversation=True, - ), - ) as mock_async_converse: - await pipeline_input.execute() - - mock_async_converse.assert_called() - - results = [ - event.data - for event in events - if event.type - in ( - assist_pipeline.PipelineEventType.INTENT_START, - assist_pipeline.PipelineEventType.INTENT_END, - ) - ] - assert results[1]["intent_output"]["continue_conversation"] is True - - # Change conversation agent to default one and register sentence trigger that should not be called - await assist_pipeline.pipeline.async_update_pipeline( - hass, pipeline, conversation_engine=None - ) - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - assert await async_setup_component( - hass, - "automation", - { - "automation": { - "trigger": { - "platform": "conversation", - "command": ["Hello"], - }, - "action": { - "set_conversation_response": "test trigger response", - }, - } - }, - ) - - # Because we did continue conversation, it should respond to the test agent again. - events.clear() - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - intent_input="Hello", - session=mock_chat_session, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.INTENT, - end_stage=assist_pipeline.PipelineStage.INTENT, - event_callback=events.append, - ), - ) - - # Ensure prepare succeeds - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", - return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), - ) as mock_prepare: - await pipeline_input.validate() - - # It requested test agent even if that was not default agent. - assert mock_prepare.mock_calls[0][1][1] == "test-agent" - - response = intent.IntentResponse("en") - response.async_set_speech("Timer set for 20 minutes") - - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", - return_value=conversation.ConversationResult( - response=response, - conversation_id=mock_chat_session.conversation_id, - ), - ) as mock_async_converse: - await pipeline_input.execute() - - mock_async_converse.assert_called() - - # Snapshot will show it was still handled by the test agent and not default agent - results = [ - event.data - for event in events - if event.type - in ( - assist_pipeline.PipelineEventType.INTENT_START, - assist_pipeline.PipelineEventType.INTENT_END, - ) - ] - assert results[0]["engine"] == "test-agent" - assert results[1]["intent_output"]["continue_conversation"] is False - - -async def test_stt_language_used_instead_of_conversation_language( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - mock_chat_session: chat_session.ChatSession, - snapshot: SnapshotAssertion, -) -> None: - """Test that the STT language is used first when the conversation language is '*' (all languages).""" - client = await hass_ws_client(hass) - - events: list[assist_pipeline.PipelineEvent] = [] - - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", - "conversation_language": MATCH_ALL, - "language": "en", - "name": "test_name", - "stt_engine": "test", - "stt_language": "en-US", - "tts_engine": "test", - "tts_language": "en-US", - "tts_voice": "Arnold Schwarzenegger", - "wake_word_entity": None, - "wake_word_id": None, - } - ) - msg = await client.receive_json() - assert msg["success"] - pipeline_id = msg["result"]["id"] - pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - intent_input="test input", - session=mock_chat_session, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.INTENT, - end_stage=assist_pipeline.PipelineStage.INTENT, - event_callback=events.append, - ), - ) - await pipeline_input.validate() - - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", - return_value=conversation.ConversationResult( - intent.IntentResponse(pipeline.language) - ), - ) as mock_async_converse: - await pipeline_input.execute() - - # Check intent start event - assert process_events(events) == snapshot - intent_start: assist_pipeline.PipelineEvent | None = None - for event in events: - if event.type == assist_pipeline.PipelineEventType.INTENT_START: - intent_start = event - break - - assert intent_start is not None - - # STT language (en-US) should be used instead of '*' - assert intent_start.data.get("language") == pipeline.stt_language - - # Check input to async_converse - mock_async_converse.assert_called_once() - assert ( - mock_async_converse.call_args_list[0].kwargs.get("language") - == pipeline.stt_language - ) - - -async def test_tts_language_used_instead_of_conversation_language( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - mock_chat_session: chat_session.ChatSession, - snapshot: SnapshotAssertion, -) -> None: - """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" - client = await hass_ws_client(hass) - - events: list[assist_pipeline.PipelineEvent] = [] - - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", - "conversation_language": MATCH_ALL, - "language": "en", - "name": "test_name", - "stt_engine": None, - "stt_language": None, - "tts_engine": None, - "tts_language": "en-us", - "tts_voice": "Arnold Schwarzenegger", - "wake_word_entity": None, - "wake_word_id": None, - } - ) - msg = await client.receive_json() - assert msg["success"] - pipeline_id = msg["result"]["id"] - pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - intent_input="test input", - session=mock_chat_session, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.INTENT, - end_stage=assist_pipeline.PipelineStage.INTENT, - event_callback=events.append, - ), - ) - await pipeline_input.validate() - - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", - return_value=conversation.ConversationResult( - intent.IntentResponse(pipeline.language) - ), - ) as mock_async_converse: - await pipeline_input.execute() - - # Check intent start event - assert process_events(events) == snapshot - intent_start: assist_pipeline.PipelineEvent | None = None - for event in events: - if event.type == assist_pipeline.PipelineEventType.INTENT_START: - intent_start = event - break - - assert intent_start is not None - - # STT language (en-US) should be used instead of '*' - assert intent_start.data.get("language") == pipeline.tts_language - - # Check input to async_converse - mock_async_converse.assert_called_once() - assert ( - mock_async_converse.call_args_list[0].kwargs.get("language") - == pipeline.tts_language - ) - - -async def test_pipeline_language_used_instead_of_conversation_language( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - mock_chat_session: chat_session.ChatSession, - snapshot: SnapshotAssertion, -) -> None: - """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" - client = await hass_ws_client(hass) - - events: list[assist_pipeline.PipelineEvent] = [] - - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", - "conversation_language": MATCH_ALL, - "language": "en", - "name": "test_name", - "stt_engine": None, - "stt_language": None, - "tts_engine": None, - "tts_language": None, - "tts_voice": None, - "wake_word_entity": None, - "wake_word_id": None, - } - ) - msg = await client.receive_json() - assert msg["success"] - pipeline_id = msg["result"]["id"] - pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - intent_input="test input", - session=mock_chat_session, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.INTENT, - end_stage=assist_pipeline.PipelineStage.INTENT, - event_callback=events.append, - ), - ) - await pipeline_input.validate() - - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", - return_value=conversation.ConversationResult( - intent.IntentResponse(pipeline.language) - ), - ) as mock_async_converse: - await pipeline_input.execute() - - # Check intent start event - assert process_events(events) == snapshot - intent_start: assist_pipeline.PipelineEvent | None = None - for event in events: - if event.type == assist_pipeline.PipelineEventType.INTENT_START: - intent_start = event - break - - assert intent_start is not None - - # STT language (en-US) should be used instead of '*' - assert intent_start.data.get("language") == pipeline.language - - # Check input to async_converse - mock_async_converse.assert_called_once() - assert ( - mock_async_converse.call_args_list[0].kwargs.get("language") - == pipeline.language - ) - - async def test_pipeline_from_audio_stream_with_cloud_auth_fail( hass: HomeAssistant, mock_stt_provider_entity: MockSTTProviderEntity, diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index d67a0fd1726..abdcb55054c 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1,13 +1,21 @@ """Websocket tests for Voice Assistant integration.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator from typing import Any -from unittest.mock import ANY, patch +from unittest.mock import ANY, AsyncMock, Mock, patch from hassil.recognize import Intent, IntentData, RecognizeResult import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol -from homeassistant.components import conversation +from homeassistant.components import ( + assist_pipeline, + conversation, + media_source, + stt, + tts, +) from homeassistant.components.assist_pipeline.const import DOMAIN from homeassistant.components.assist_pipeline.pipeline import ( STORAGE_KEY, @@ -24,14 +32,23 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_migrate_engine, async_update_pipeline, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.const import MATCH_ALL +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import chat_session, intent, llm from homeassistant.setup import async_setup_component -from . import MANY_LANGUAGES -from .conftest import MockSTTProviderEntity, MockTTSProvider +from . import MANY_LANGUAGES, process_events +from .conftest import ( + MockSTTProvider, + MockSTTProviderEntity, + MockTTSEntity, + MockTTSProvider, + MockWakeWordEntity, + make_10ms_chunk, +) from tests.common import flush_store +from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) @@ -47,6 +64,12 @@ async def load_homeassistant(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "homeassistant", {}) +@pytest.fixture +async def disable_tts_entity(mock_tts_entity: tts.TextToSpeechEntity) -> None: + """Disable the TTS entity.""" + mock_tts_entity._attr_entity_registry_enabled_default = False + + @pytest.mark.usefixtures("init_components") async def test_load_pipelines(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" @@ -119,6 +142,22 @@ async def test_load_pipelines(hass: HomeAssistant) -> None: assert store1.async_get_preferred_item() == store2.async_get_preferred_item() +@pytest.fixture(autouse=True) +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: + yield mock_ulid_now + + +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -252,6 +291,7 @@ async def test_migrate_pipeline_store( @pytest.mark.usefixtures("init_supporting_components") +@pytest.mark.usefixtures("disable_tts_entity") async def test_create_default_pipeline(hass: HomeAssistant) -> None: """Test async_create_default_pipeline.""" assert await async_setup_component(hass, "assist_pipeline", {}) @@ -399,6 +439,7 @@ async def test_default_pipeline_no_stt_tts( ], ) @pytest.mark.usefixtures("init_supporting_components") +@pytest.mark.usefixtures("disable_tts_entity") async def test_default_pipeline( hass: HomeAssistant, mock_stt_provider_entity: MockSTTProviderEntity, @@ -443,6 +484,7 @@ async def test_default_pipeline( @pytest.mark.usefixtures("init_supporting_components") +@pytest.mark.usefixtures("disable_tts_entity") async def test_default_pipeline_unsupported_stt_language( hass: HomeAssistant, mock_stt_provider_entity: MockSTTProviderEntity ) -> None: @@ -473,6 +515,7 @@ async def test_default_pipeline_unsupported_stt_language( @pytest.mark.usefixtures("init_supporting_components") +@pytest.mark.usefixtures("disable_tts_entity") async def test_default_pipeline_unsupported_tts_language( hass: HomeAssistant, mock_tts_provider: MockTTSProvider ) -> None: @@ -697,3 +740,1090 @@ def test_fallback_intent_filter() -> None: ) is False ) + + +async def test_wake_word_detection_aborted( + hass: HomeAssistant, + mock_stt_provider: MockSTTProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, +) -> None: + """Test wake word stream is first detected, then aborted.""" + + events: list[assist_pipeline.PipelineEvent] = [] + + async def audio_data(): + yield make_10ms_chunk(b"silence!") + yield make_10ms_chunk(b"wake word!") + yield make_10ms_chunk(b"part1") + yield make_10ms_chunk(b"part2") + yield b"" + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + session=mock_chat_session, + device_id=None, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output=None, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ), + ) + await pipeline_input.validate() + + updates = pipeline.to_json() + updates.pop("id") + await pipeline_store.async_update_item( + pipeline_id, + updates, + ) + await pipeline_input.execute() + + assert process_events(events) == snapshot + + +def test_pipeline_run_equality(hass: HomeAssistant, init_components) -> None: + """Test that pipeline run equality uses unique id.""" + + def event_callback(event): + pass + + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass) + run_1 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + run_2 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + + assert run_1 == run_1 # noqa: PLR0124 + assert run_1 != run_2 + assert run_1 != 1234 + + +async def test_tts_audio_output( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_entity: MockTTSProvider, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, +) -> None: + """Test using tts_audio_output with wav sets options correctly.""" + client = await hass_client() + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + tts_input="This is a test.", + session=mock_chat_session, + device_id=None, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.TTS, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output="wav", + ), + ) + await pipeline_input.validate() + + # Verify TTS audio settings + assert pipeline_input.run.tts_stream.options is not None + assert pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) + == 16000 + ) + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) + == 1 + ) + + with patch.object(mock_tts_entity, "get_tts_audio") as mock_get_tts_audio: + await pipeline_input.execute() + + for event in events: + if event.type == assist_pipeline.PipelineEventType.TTS_END: + # We must fetch the media URL to trigger the TTS + assert event.data + await client.get(event.data["tts_output"]["url"]) + + # Ensure that no unsupported options were passed in + assert mock_get_tts_audio.called + options = mock_get_tts_audio.call_args_list[0].kwargs["options"] + extra_options = set(options).difference(mock_tts_entity.supported_options) + assert len(extra_options) == 0, extra_options + + +async def test_tts_wav_preferred_format( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_entity: MockTTSEntity, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that preferred format options are given to the TTS system if supported.""" + client = await hass_client() + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + tts_input="This is a test.", + session=mock_chat_session, + device_id=None, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.TTS, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output="wav", + ), + ) + await pipeline_input.validate() + + # Make the TTS provider support preferred format options + supported_options = list(mock_tts_entity.supported_options or []) + supported_options.extend( + [ + tts.ATTR_PREFERRED_FORMAT, + tts.ATTR_PREFERRED_SAMPLE_RATE, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS, + tts.ATTR_PREFERRED_SAMPLE_BYTES, + ] + ) + + with ( + patch.object(mock_tts_entity, "_supported_options", supported_options), + patch.object(mock_tts_entity, "get_tts_audio") as mock_get_tts_audio, + ): + await pipeline_input.execute() + + for event in events: + if event.type == assist_pipeline.PipelineEventType.TTS_END: + # We must fetch the media URL to trigger the TTS + assert event.data + await client.get(event.data["tts_output"]["url"]) + + assert mock_get_tts_audio.called + options = mock_get_tts_audio.call_args_list[0].kwargs["options"] + + # We should have received preferred format options in get_tts_audio + assert options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 16000 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 1 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 + + +async def test_tts_dict_preferred_format( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_entity: MockTTSEntity, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that preferred format options are given to the TTS system if supported.""" + client = await hass_client() + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + tts_input="This is a test.", + session=mock_chat_session, + device_id=None, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.TTS, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output={ + tts.ATTR_PREFERRED_FORMAT: "flac", + tts.ATTR_PREFERRED_SAMPLE_RATE: 48000, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + }, + ), + ) + await pipeline_input.validate() + + # Make the TTS provider support preferred format options + supported_options = list(mock_tts_entity.supported_options or []) + supported_options.extend( + [ + tts.ATTR_PREFERRED_FORMAT, + tts.ATTR_PREFERRED_SAMPLE_RATE, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS, + tts.ATTR_PREFERRED_SAMPLE_BYTES, + ] + ) + + with ( + patch.object(mock_tts_entity, "_supported_options", supported_options), + patch.object(mock_tts_entity, "get_tts_audio") as mock_get_tts_audio, + ): + await pipeline_input.execute() + + for event in events: + if event.type == assist_pipeline.PipelineEventType.TTS_END: + # We must fetch the media URL to trigger the TTS + assert event.data + await client.get(event.data["tts_output"]["url"]) + + assert mock_get_tts_audio.called + options = mock_get_tts_audio.call_args_list[0].kwargs["options"] + + # We should have received preferred format options in get_tts_audio + assert options.get(tts.ATTR_PREFERRED_FORMAT) == "flac" + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 48000 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 2 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 + + +async def test_sentence_trigger_overrides_conversation_agent( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that sentence triggers are checked before a non-default conversation agent.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": [ + "test trigger sentence", + ], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test trigger sentence", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + intent_agent="test-agent", # not the default agent + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo( + id="test-agent", + name="Test Agent", + supports_streaming=False, + ), + ): + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" + ) as mock_async_converse: + await pipeline_input.execute() + + # Sentence trigger should have been handled + mock_async_converse.assert_not_called() + + # Verify sentence trigger response + intent_end_event = next( + ( + e + for e in events + if e.type == assist_pipeline.PipelineEventType.INTENT_END + ), + None, + ) + assert (intent_end_event is not None) and intent_end_event.data + assert ( + intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ + "speech" + ] + == "test trigger response" + ) + + +async def test_prefer_local_intents( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that the default agent is checked first when local intents are preferred.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Reuse custom sentences in test config + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + response = intent_obj.create_response() + response.async_set_speech("Order confirmed") + return response + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent", prefer_local_intents=True + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="I'd like to order a stout please", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo( + id="test-agent", + name="Test Agent", + supports_streaming=False, + ), + ): + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" + ) as mock_async_converse: + await pipeline_input.execute() + + # Test agent should not have been called + mock_async_converse.assert_not_called() + + # Verify local intent response + intent_end_event = next( + ( + e + for e in events + if e.type == assist_pipeline.PipelineEventType.INTENT_END + ), + None, + ) + assert (intent_end_event is not None) and intent_end_event.data + assert ( + intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ + "speech" + ] + == "Order confirmed" + ) + + +async def test_intent_continue_conversation( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that a conversation agent flagging continue conversation gets response.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent" + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Set a timer", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo( + id="test-agent", + name="Test Agent", + supports_streaming=False, + ), + ): + await pipeline_input.validate() + + response = intent.IntentResponse("en") + response.async_set_speech("For how long?") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + continue_conversation=True, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[1]["intent_output"]["continue_conversation"] is True + + # Change conversation agent to default one and register sentence trigger that should not be called + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine=None + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["Hello"], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + # Because we did continue conversation, it should respond to the test agent again. + events.clear() + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Hello", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo( + id="test-agent", + name="Test Agent", + supports_streaming=False, + ), + ) as mock_prepare: + await pipeline_input.validate() + + # It requested test agent even if that was not default agent. + assert mock_prepare.mock_calls[0][1][1] == "test-agent" + + response = intent.IntentResponse("en") + response.async_set_speech("Timer set for 20 minutes") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + # Snapshot will show it was still handled by the test agent and not default agent + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[0]["engine"] == "test-agent" + assert results[1]["intent_output"]["continue_conversation"] is False + + +async def test_stt_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, +) -> None: + """Test that the STT language is used first when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.stt_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.stt_language + ) + + +async def test_tts_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, +) -> None: + """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": "en-us", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.tts_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.tts_language + ) + + +async def test_pipeline_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, +) -> None: + """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.language + ) + + +@pytest.mark.parametrize( + ("to_stream_deltas", "expected_chunks", "chunk_text"), + [ + # Size below STREAM_RESPONSE_CHUNKS + ( + ( + [ + "hello,", + " ", + "how", + " ", + "are", + " ", + "you", + "?", + ], + ), + # We are not streaming, so 0 chunks via streaming method + 0, + "", + ), + # Size above STREAM_RESPONSE_CHUNKS + ( + ( + [ + "hello, ", + "how ", + "are ", + "you", + "? ", + "I'm ", + "doing ", + "well", + ", ", + "thank ", + "you", + ". ", + "What ", + "about ", + "you", + "?", + "!", + ], + ), + # We are streamed. First 15 chunks are grouped into 1 chunk + # and the rest are streamed + 3, + "hello, how are you? I'm doing well, thank you. What about you?!", + ), + # Stream a bit, then a tool call, then stream some more + ( + ( + [ + "hello, ", + "how ", + "are ", + "you", + "? ", + ], + { + "tool_calls": [ + llm.ToolInput( + tool_name="test_tool", + tool_args={}, + id="test_tool_id", + ) + ], + }, + [ + "I'm ", + "doing ", + "well", + ", ", + "thank ", + "you", + ".", + ], + ), + # 1 chunk before tool call, then 7 after + 8, + "hello, how are you? I'm doing well, thank you.", + ), + ], +) +async def test_chat_log_tts_streaming( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, + mock_tts_entity: MockTTSEntity, + pipeline_data: assist_pipeline.pipeline.PipelineData, + to_stream_deltas: tuple[dict | list[str]], + expected_chunks: int, + chunk_text: str, +) -> None: + """Test that chat log events are streamed to the TTS entity.""" + text_deltas = [ + delta + for deltas in to_stream_deltas + if isinstance(deltas, list) + for delta in deltas + ] + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent" + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Set a timer", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + ), + ) + + received_tts = [] + + 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: + received_tts.append(msg) + yield msg.encode() + + return tts.TTSAudioResponse( + extension="mp3", + data_gen=gen_data(), + ) + + async def async_get_tts_audio( + message: str, + language: str, + options: dict[str, Any] | None = None, + ) -> tts.TtsAudioType: + """Mock get TTS audio.""" + return ("mp3", b"".join([chunk.encode() for chunk in text_deltas])) + + mock_tts_entity.async_get_tts_audio = async_get_tts_audio + mock_tts_entity.async_stream_tts_audio = async_stream_tts_audio + mock_tts_entity.async_supports_streaming_input = Mock(return_value=True) + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo( + id="test-agent", + name="Test Agent", + supports_streaming=True, + ), + ): + await pipeline_input.validate() + + async def mock_converse( + hass: HomeAssistant, + text: str, + conversation_id: str | None, + context: Context, + language: str | None = None, + agent_id: str | None = None, + device_id: str | None = None, + extra_system_prompt: str | None = None, + ): + """Mock converse.""" + conversation_input = conversation.ConversationInput( + text=text, + context=context, + conversation_id=conversation_id, + device_id=device_id, + language=language, + agent_id=agent_id, + extra_system_prompt=extra_system_prompt, + ) + + async def stream_llm_response(): + for deltas in to_stream_deltas: + if isinstance(deltas, dict): + yield deltas + else: + yield {"role": "assistant"} + for chunk in deltas: + yield {"content": chunk} + + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + conversation.async_get_chat_log( + hass, + session, + conversation_input, + ) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + async for _content in chat_log.async_add_delta_content_stream( + agent_id, stream_llm_response() + ): + pass + intent_response = intent.IntentResponse(language) + intent_response.async_set_speech("".join(to_stream_deltas[-1])) + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema({}) + mock_tool.async_call.return_value = "Test response" + + with ( + patch( + "homeassistant.helpers.llm.AssistAPI._async_get_tools", + return_value=[mock_tool], + ), + patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + mock_converse, + ), + ): + await pipeline_input.execute() + + stream = tts.async_get_stream(hass, events[0].data["tts_output"]["token"]) + assert stream is not None + tts_result = "".join( + [chunk.decode() async for chunk in stream.async_stream_result()] + ) + + streamed_text = "".join(text_deltas) + assert tts_result == streamed_text + assert len(received_tts) == expected_chunks + assert "".join(received_tts) == chunk_text + + assert process_events(events) == snapshot diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 060c0dce660..bf9818f2a5f 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -1153,9 +1153,9 @@ async def test_get_pipeline( "name": "Home Assistant", "stt_engine": "stt.mock_stt", "stt_language": "en-US", - "tts_engine": "test", - "tts_language": "en-US", - "tts_voice": "james_earl_jones", + "tts_engine": "tts.test", + "tts_language": "en_US", + "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, "prefer_local_intents": False, @@ -1179,9 +1179,9 @@ async def test_get_pipeline( # It found these defaults "stt_engine": "stt.mock_stt", "stt_language": "en-US", - "tts_engine": "test", - "tts_language": "en-US", - "tts_voice": "james_earl_jones", + "tts_engine": "tts.test", + "tts_language": "en_US", + "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, "prefer_local_intents": False, @@ -1266,9 +1266,9 @@ async def test_list_pipelines( "name": "Home Assistant", "stt_engine": "stt.mock_stt", "stt_language": "en-US", - "tts_engine": "test", - "tts_language": "en-US", - "tts_voice": "james_earl_jones", + "tts_engine": "tts.test", + "tts_language": "en_US", + "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, "prefer_local_intents": False, diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index bcdd4d55330..563221635f8 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -4,7 +4,7 @@ import datetime from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yalexs.pubnub_async import AugustPubNub from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN diff --git a/tests/components/august/test_diagnostics.py b/tests/components/august/test_diagnostics.py index 0b00bde7b23..cdc538ca6bd 100644 --- a/tests/components/august/test_diagnostics.py +++ b/tests/components/august/test_diagnostics.py @@ -1,6 +1,6 @@ """Test august diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 065ffef91ff..a1ba83ecb01 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -6,7 +6,7 @@ from unittest.mock import Mock from aiohttp import ClientResponseError from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME from yalexs.pubnub_async import AugustPubNub diff --git a/tests/components/autarco/snapshots/test_sensor.ambr b/tests/components/autarco/snapshots/test_sensor.ambr index d57f4be5da0..73a07d71656 100644 --- a/tests/components/autarco/snapshots/test_sensor.ambr +++ b/tests/components/autarco/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charged month', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charged_month', 'unique_id': '1_battery_charged_month', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charged today', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charged_today', 'unique_id': '1_battery_charged_today', @@ -127,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charged total', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charged_total', 'unique_id': '1_battery_charged_total', @@ -179,12 +191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Discharged month', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'discharged_month', 'unique_id': '1_battery_discharged_month', @@ -231,12 +247,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Discharged today', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'discharged_today', 'unique_id': '1_battery_discharged_today', @@ -283,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Discharged total', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'discharged_total', 'unique_id': '1_battery_discharged_total', @@ -335,12 +359,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Flow now', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flow_now', 'unique_id': '1_battery_flow_now', @@ -393,6 +421,7 @@ 'original_name': 'State of charge', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state_of_charge', 'unique_id': '1_battery_state_of_charge', @@ -439,12 +468,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy AC output total', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'out_ac_energy_total', 'unique_id': 'test-serial-1_out_ac_energy_total', @@ -491,12 +524,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power AC output', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'out_ac_power', 'unique_id': 'test-serial-1_out_ac_power', @@ -543,12 +580,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy AC output total', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'out_ac_energy_total', 'unique_id': 'test-serial-2_out_ac_energy_total', @@ -595,12 +636,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power AC output', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'out_ac_power', 'unique_id': 'test-serial-2_out_ac_power', @@ -647,12 +692,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy production month', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_production_month', 'unique_id': '1_solar_energy_production_month', @@ -699,12 +748,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_production_today', 'unique_id': '1_solar_energy_production_today', @@ -751,12 +804,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy production total', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_production_total', 'unique_id': '1_solar_energy_production_total', @@ -803,12 +860,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power production', 'platform': 'autarco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_production', 'unique_id': '1_solar_power_production', diff --git a/tests/components/autarco/test_diagnostics.py b/tests/components/autarco/test_diagnostics.py index 1d12a2c1894..461f65becdb 100644 --- a/tests/components/autarco/test_diagnostics.py +++ b/tests/components/autarco/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/autarco/test_sensor.py b/tests/components/autarco/test_sensor.py index c7e65baba70..9cdc93e98b0 100644 --- a/tests/components/autarco/test_sensor.py +++ b/tests/components/autarco/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from autarco import AutarcoConnectionError from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/aws_s3/test_backup.py b/tests/components/aws_s3/test_backup.py index a8b24ec1ab4..bf5baf2044b 100644 --- a/tests/components/aws_s3/test_backup.py +++ b/tests/components/aws_s3/test_backup.py @@ -114,21 +114,23 @@ async def test_agents_list_backups( 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, } }, + "backup_id": test_backup.backup_id, + "database_included": test_backup.database_included, + "date": test_backup.date, + "extra_metadata": test_backup.extra_metadata, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], + "folders": test_backup.folders, + "homeassistant_included": test_backup.homeassistant_included, + "homeassistant_version": test_backup.homeassistant_version, + "name": test_backup.name, "with_automatic_settings": None, } ] @@ -152,21 +154,23 @@ async def test_agents_get_backup( 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, } }, + "backup_id": test_backup.backup_id, + "database_included": test_backup.database_included, + "date": test_backup.date, + "extra_metadata": test_backup.extra_metadata, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], + "folders": test_backup.folders, + "homeassistant_included": test_backup.homeassistant_included, + "homeassistant_version": test_backup.homeassistant_version, + "name": test_backup.name, "with_automatic_settings": None, } diff --git a/tests/components/axis/snapshots/test_binary_sensor.ambr b/tests/components/axis/snapshots/test_binary_sensor.ambr index 6c0f3ead473..fb762800c12 100644 --- a/tests/components/axis/snapshots/test_binary_sensor.ambr +++ b/tests/components/axis/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'DayNight 1', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tns1:VideoSource/tnsaxis:DayNightVision-1', @@ -75,6 +76,7 @@ 'original_name': 'Object Analytics Device1Scenario8', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario8-Device1Scenario8', @@ -123,6 +125,7 @@ 'original_name': 'Sound 1', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tns1:AudioSource/tnsaxis:TriggerLevel-1', @@ -171,6 +174,7 @@ 'original_name': 'PIR sensor', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tns1:Device/tnsaxis:IO/Port-0', @@ -219,6 +223,7 @@ 'original_name': 'PIR 0', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tns1:Device/tnsaxis:Sensor/PIR-0', @@ -267,6 +272,7 @@ 'original_name': 'Fence Guard Profile 1', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1Profile1-Camera1Profile1', @@ -315,6 +321,7 @@ 'original_name': 'Motion Guard Profile 1', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/MotionGuard/Camera1Profile1-Camera1Profile1', @@ -363,6 +370,7 @@ 'original_name': 'Loitering Guard Profile 1', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/LoiteringGuard/Camera1Profile1-Camera1Profile1', @@ -411,6 +419,7 @@ 'original_name': 'VMD4 Profile 1', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/VMD/Camera1Profile1-Camera1Profile1', @@ -459,6 +468,7 @@ 'original_name': 'Object Analytics Scenario 1', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1-Device1Scenario1', @@ -507,6 +517,7 @@ 'original_name': 'VMD4 Camera1Profile9', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/VMD/Camera1Profile9-Camera1Profile9', diff --git a/tests/components/axis/snapshots/test_camera.ambr b/tests/components/axis/snapshots/test_camera.ambr index 1e70e2a799f..68b9cd07e53 100644 --- a/tests/components/axis/snapshots/test_camera.ambr +++ b/tests/components/axis/snapshots/test_camera.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-camera', @@ -39,7 +40,6 @@ 'access_token': '1', 'entity_picture': '/api/camera_proxy/camera.home?token=1', 'friendly_name': 'home', - 'frontend_stream_type': , 'supported_features': , }), 'context': , @@ -78,6 +78,7 @@ 'original_name': None, 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-camera', @@ -90,7 +91,6 @@ 'access_token': '1', 'entity_picture': '/api/camera_proxy/camera.home?token=1', 'friendly_name': 'home', - 'frontend_stream_type': , 'supported_features': , }), 'context': , diff --git a/tests/components/axis/snapshots/test_light.ambr b/tests/components/axis/snapshots/test_light.ambr index d8d01543ee5..aec750ecda3 100644 --- a/tests/components/axis/snapshots/test_light.ambr +++ b/tests/components/axis/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': 'IR Light 0', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tns1:Device/tnsaxis:Light/Status-0', diff --git a/tests/components/axis/snapshots/test_switch.ambr b/tests/components/axis/snapshots/test_switch.ambr index fa6091550e5..1e9a2d0b068 100644 --- a/tests/components/axis/snapshots/test_switch.ambr +++ b/tests/components/axis/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Doorbell', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tns1:Device/Trigger/Relay-0', @@ -75,6 +76,7 @@ 'original_name': 'Relay 1', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tns1:Device/Trigger/Relay-1', @@ -123,6 +125,7 @@ 'original_name': 'Doorbell', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tns1:Device/Trigger/Relay-0', @@ -171,6 +174,7 @@ 'original_name': 'Relay 1', 'platform': 'axis', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:40:8c:12:34:56-tns1:Device/Trigger/Relay-1', diff --git a/tests/components/axis/test_binary_sensor.py b/tests/components/axis/test_binary_sensor.py index 766a51463a4..e13d77c73c8 100644 --- a/tests/components/axis/test_binary_sensor.py +++ b/tests/components/axis/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import Platform diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index 9dcfbac4e7b..1f6f1bf44f8 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import camera from homeassistant.components.axis.const import CONF_STREAM_PROFILE diff --git a/tests/components/axis/test_diagnostics.py b/tests/components/axis/test_diagnostics.py index e96ba88c2cd..9107ef2e8a3 100644 --- a/tests/components/axis/test_diagnostics.py +++ b/tests/components/axis/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Axis diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index b2f2d15d989..a7da7891d50 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -9,7 +9,7 @@ from unittest.mock import ANY, Mock, call, patch import axis as axislib import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import axis from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN diff --git a/tests/components/axis/test_light.py b/tests/components/axis/test_light.py index c33af5ec3a4..ccff3d06e2d 100644 --- a/tests/components/axis/test_light.py +++ b/tests/components/axis/test_light.py @@ -6,7 +6,7 @@ from unittest.mock import patch from axis.models.api import CONTEXT import pytest import respx -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN from homeassistant.const import ( diff --git a/tests/components/axis/test_switch.py b/tests/components/axis/test_switch.py index 964cfdae64c..c0203bc3d4c 100644 --- a/tests/components/axis/test_switch.py +++ b/tests/components/axis/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import patch from axis.models.api import CONTEXT import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( diff --git a/tests/components/azure_devops/snapshots/test_sensor.ambr b/tests/components/azure_devops/snapshots/test_sensor.ambr index 3fe4d470a63..865cd79ee1f 100644 --- a/tests/components/azure_devops/snapshots/test_sensor.ambr +++ b/tests/components/azure_devops/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'CI latest build', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latest_build', 'unique_id': 'testorg_1234_9876_latest_build', @@ -86,6 +87,7 @@ 'original_name': 'CI latest build finish time', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'finish_time', 'unique_id': 'testorg_1234_9876_finish_time', @@ -134,6 +136,7 @@ 'original_name': 'CI latest build ID', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'build_id', 'unique_id': 'testorg_1234_9876_build_id', @@ -181,6 +184,7 @@ 'original_name': 'CI latest build queue time', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'queue_time', 'unique_id': 'testorg_1234_9876_queue_time', @@ -229,6 +233,7 @@ 'original_name': 'CI latest build reason', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reason', 'unique_id': 'testorg_1234_9876_reason', @@ -276,6 +281,7 @@ 'original_name': 'CI latest build result', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'result', 'unique_id': 'testorg_1234_9876_result', @@ -323,6 +329,7 @@ 'original_name': 'CI latest build source branch', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'source_branch', 'unique_id': 'testorg_1234_9876_source_branch', @@ -370,6 +377,7 @@ 'original_name': 'CI latest build source version', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'source_version', 'unique_id': 'testorg_1234_9876_source_version', @@ -417,6 +425,7 @@ 'original_name': 'CI latest build start time', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_time', 'unique_id': 'testorg_1234_9876_start_time', @@ -465,6 +474,7 @@ 'original_name': 'CI latest build URL', 'platform': 'azure_devops', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'url', 'unique_id': 'testorg_1234_9876_url', diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py index ebb491c2b7c..8fb81e7dbc4 100644 --- a/tests/components/azure_storage/test_backup.py +++ b/tests/components/azure_storage/test_backup.py @@ -93,14 +93,16 @@ async def test_agents_list_backups( } }, "backup_id": "23e64aec", - "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "date": "2024-11-22T11:48:48.727189+01:00", + "extra_metadata": {}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "failed_agent_ids": [], - "extra_metadata": {}, "with_automatic_settings": None, } ] @@ -129,14 +131,16 @@ async def test_agents_get_backup( } }, "backup_id": "23e64aec", - "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "date": "2024-11-22T11:48:48.727189+01:00", + "extra_metadata": {}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", - "extra_metadata": {}, "name": "Core 2024.12.0.dev0", - "failed_agent_ids": [], "with_automatic_settings": None, } diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index d391df44475..8fffdba7cc2 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -110,8 +110,10 @@ CONFIG_DIR_DIRS = { def mock_create_backup() -> Generator[AsyncMock]: """Mock manager create backup.""" mock_written_backup = MagicMock(spec_set=WrittenBackup) + mock_written_backup.addon_errors = {} mock_written_backup.backup.backup_id = "abc123" mock_written_backup.backup.protected = False + mock_written_backup.folder_errors = {} mock_written_backup.open_stream = AsyncMock() mock_written_backup.release_stream = AsyncMock() fut: Future[MagicMock] = Future() diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted_skip_core2 b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted_skip_core2 new file mode 100644 index 00000000000..ba53b103b03 Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted_skip_core2 differ diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_skip_core2 b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_skip_core2 new file mode 100644 index 00000000000..40216194671 Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_skip_core2 differ diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 7cbbb9ddbce..bf6305e8479 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -75,8 +75,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -102,8 +106,12 @@ 'instance_id': 'unknown_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', diff --git a/tests/components/backup/snapshots/test_event.ambr b/tests/components/backup/snapshots/test_event.ambr new file mode 100644 index 00000000000..78f60bf8d20 --- /dev/null +++ b/tests/components/backup/snapshots/test_event.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_event_entity[event.backup_automatic_backup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'completed', + 'failed', + 'in_progress', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.backup_automatic_backup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic backup', + 'platform': 'backup', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_backup_event', + 'unique_id': 'automatic_backup_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_entity[event.backup_automatic_backup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'completed', + 'failed', + 'in_progress', + ]), + 'friendly_name': 'Backup Automatic backup', + }), + 'context': , + 'entity_id': 'event.backup_automatic_backup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/backup/snapshots/test_onboarding.ambr b/tests/components/backup/snapshots/test_onboarding.ambr index 48ddf30d1f2..975406fc265 100644 --- a/tests/components/backup/snapshots/test_onboarding.ambr +++ b/tests/components/backup/snapshots/test_onboarding.ambr @@ -23,8 +23,12 @@ 'instance_id': 'abc123', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -50,8 +54,12 @@ 'instance_id': 'unknown_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', diff --git a/tests/components/backup/snapshots/test_sensors.ambr b/tests/components/backup/snapshots/test_sensors.ambr index be12afdbf1e..034ca91239b 100644 --- a/tests/components/backup/snapshots/test_sensors.ambr +++ b/tests/components/backup/snapshots/test_sensors.ambr @@ -35,6 +35,7 @@ 'original_name': 'Backup Manager state', 'platform': 'backup', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backup_manager_state', 'unique_id': 'backup_manager_state', @@ -62,6 +63,55 @@ 'state': 'idle', }) # --- +# name: test_sensors[sensor.backup_last_attempted_automatic_backup-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.backup_last_attempted_automatic_backup', + '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 attempted automatic backup', + 'platform': 'backup', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_attempted_automatic_backup', + 'unique_id': 'last_attempted_automatic_backup', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_last_attempted_automatic_backup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Backup Last attempted automatic backup', + }), + 'context': , + 'entity_id': 'sensor.backup_last_attempted_automatic_backup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.backup_last_successful_automatic_backup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -90,6 +140,7 @@ 'original_name': 'Last successful automatic backup', 'platform': 'backup', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_successful_automatic_backup', 'unique_id': 'last_successful_automatic_backup', @@ -138,6 +189,7 @@ 'original_name': 'Next scheduled automatic backup', 'platform': 'backup', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'next_scheduled_automatic_backup', 'unique_id': 'next_scheduled_automatic_backup', diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 6f1bce8d5e4..aa9ccde4b8a 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -5,9 +5,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -40,7 +44,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -50,9 +54,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -86,7 +94,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -96,9 +104,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -131,7 +143,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -141,9 +153,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -177,7 +193,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -187,9 +203,19 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + dict({ + 'name': 'Test add-on', + 'slug': 'test_addon', + 'version': '1.0.0', + }), + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + 'ssl', + ]), }), ]), 'config': dict({ @@ -226,7 +252,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -236,9 +262,19 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + dict({ + 'name': 'Test add-on', + 'slug': 'test_addon', + 'version': '1.0.0', + }), + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + 'ssl', + ]), }), ]), 'config': dict({ @@ -276,7 +312,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -286,9 +322,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -325,7 +365,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -335,9 +375,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -375,7 +419,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -385,9 +429,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -424,7 +472,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -434,9 +482,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -474,7 +526,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -484,9 +536,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -526,7 +582,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -536,9 +592,13 @@ 'backups': list([ dict({ 'backup_id': 'abc123', + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + ]), }), ]), 'config': dict({ @@ -579,7 +639,132 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data6] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_addons': list([ + dict({ + 'name': 'Test add-on', + 'slug': 'test_addon', + 'version': '1.0.0', + }), + ]), + 'failed_agent_ids': list([ + 'test.remote', + ]), + 'failed_folders': list([ + 'ssl', + ]), + }), + ]), + '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': 7, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data6].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_addons': list([ + dict({ + 'name': 'Test add-on', + 'slug': 'test_addon', + 'version': '1.0.0', + }), + ]), + 'failed_agent_ids': list([ + 'test.remote', + ]), + 'failed_folders': list([ + 'ssl', + ]), + }), + ]), + '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': 7, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 7528785ab0d..1ce16b2c7d3 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -1312,7 +1312,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -1429,7 +1429,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -1546,7 +1546,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -1677,7 +1677,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -1955,7 +1955,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -2070,7 +2070,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -2185,7 +2185,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -2302,7 +2302,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -2421,7 +2421,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -2538,7 +2538,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -2659,7 +2659,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -2784,7 +2784,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -2901,7 +2901,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -3018,7 +3018,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -3135,7 +3135,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -3252,7 +3252,7 @@ }), }), 'key': 'backup', - 'minor_version': 6, + 'minor_version': 7, 'version': 1, }) # --- @@ -4397,8 +4397,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -4478,8 +4482,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -4540,8 +4548,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -4586,8 +4598,12 @@ 'instance_id': 'unknown_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -4643,8 +4659,12 @@ 'instance_id': 'unknown_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -4698,8 +4718,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -4760,8 +4784,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -4823,8 +4851,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -4886,9 +4918,19 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + dict({ + 'name': 'Test add-on', + 'slug': 'test_addon', + 'version': '1.0.0', + }), + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + 'ssl', + ]), 'folders': list([ 'media', 'share', @@ -4949,8 +4991,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5011,8 +5057,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5074,8 +5124,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5137,9 +5191,19 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + dict({ + 'name': 'Test add-on', + 'slug': 'test_addon', + 'version': '1.0.0', + }), + ]), 'failed_agent_ids': list([ 'test.remote', ]), + 'failed_folders': list([ + 'ssl', + ]), 'folders': list([ 'media', 'share', @@ -5200,8 +5264,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5243,8 +5311,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5302,8 +5374,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5358,8 +5434,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5402,8 +5482,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5446,8 +5530,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5715,8 +5803,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5766,8 +5858,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5821,8 +5917,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5867,8 +5967,12 @@ 'instance_id': 'unknown_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5899,8 +6003,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -5951,8 +6059,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -6003,8 +6115,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', @@ -6055,8 +6171,12 @@ 'instance_id': 'our_uuid', 'with_automatic_settings': True, }), + 'failed_addons': list([ + ]), 'failed_agent_ids': list([ ]), + 'failed_folders': list([ + ]), 'folders': list([ 'media', 'share', diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index c9d797f4e30..5a33bf39390 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -10,7 +10,7 @@ from tarfile import TarError from unittest.mock import MagicMock, mock_open, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant diff --git a/tests/components/backup/test_diagnostics.py b/tests/components/backup/test_diagnostics.py index a66b4a9a2ea..8f6c501ca86 100644 --- a/tests/components/backup/test_diagnostics.py +++ b/tests/components/backup/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests the diagnostics for Home Assistant Backup integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/backup/test_event.py b/tests/components/backup/test_event.py new file mode 100644 index 00000000000..dc7f57018bb --- /dev/null +++ b/tests/components/backup/test_event.py @@ -0,0 +1,95 @@ +"""The tests for the Backup event entity.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup.const import DOMAIN +from homeassistant.components.backup.event import ATTR_BACKUP_STAGE, ATTR_FAILED_REASON +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_backup_integration + +from tests.common import snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_event_entity( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test automatic backup event entity.""" + with patch("homeassistant.components.backup.PLATFORMS", [Platform.EVENT]): + await setup_backup_integration(hass, with_hassio=False) + await hass.async_block_till_done(wait_background_tasks=True) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_event_entity_backup_completed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test completed automatic backup event.""" + with patch("homeassistant.components.backup.PLATFORMS", [Platform.EVENT]): + await setup_backup_integration(hass, with_hassio=False) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("event.backup_automatic_backup") + assert state.attributes[ATTR_EVENT_TYPE] is None + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + assert await client.receive_json() + + state = hass.states.get("event.backup_automatic_backup") + assert state.attributes[ATTR_EVENT_TYPE] == "in_progress" + assert state.attributes[ATTR_BACKUP_STAGE] is not None + assert state.attributes[ATTR_FAILED_REASON] is None + + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("event.backup_automatic_backup") + assert state.attributes[ATTR_EVENT_TYPE] == "completed" + assert state.attributes[ATTR_BACKUP_STAGE] is None + assert state.attributes[ATTR_FAILED_REASON] is None + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_event_entity_backup_failed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + create_backup: AsyncMock, +) -> None: + """Test failed automatic backup event.""" + with patch("homeassistant.components.backup.PLATFORMS", [Platform.EVENT]): + await setup_backup_integration(hass, with_hassio=False) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("event.backup_automatic_backup") + assert state.attributes[ATTR_EVENT_TYPE] is None + + create_backup.side_effect = Exception("Boom!") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + assert await client.receive_json() + + state = hass.states.get("event.backup_automatic_backup") + assert state.attributes[ATTR_EVENT_TYPE] == "failed" + assert state.attributes[ATTR_BACKUP_STAGE] is None + assert state.attributes[ATTR_FAILED_REASON] == "unknown_error" diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 04072dae864..59c1bf24b21 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -35,6 +35,8 @@ from homeassistant.components.backup import ( from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( + AddonErrorData, + AddonInfo, BackupManagerError, BackupManagerExceptionGroup, BackupManagerState, @@ -123,7 +125,9 @@ async def test_create_backup_service( new_backup = NewBackup(backup_job_id="time-123") backup_task = AsyncMock( return_value=WrittenBackup( + addon_errors={}, backup=TEST_BACKUP_ABC123, + folder_errors={}, open_stream=AsyncMock(), release_stream=AsyncMock(), ), @@ -320,7 +324,9 @@ async def test_async_create_backup( new_backup = NewBackup(backup_job_id="time-123") backup_task = AsyncMock( return_value=WrittenBackup( + addon_errors={}, backup=TEST_BACKUP_ABC123, + folder_errors={}, open_stream=AsyncMock(), release_stream=AsyncMock(), ), @@ -648,7 +654,9 @@ async def test_initiate_backup( "database_included": include_database, "date": ANY, "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, + "failed_addons": [], "failed_agent_ids": expected_failed_agent_ids, + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", @@ -701,7 +709,9 @@ async def test_initiate_backup_with_agent_error( "instance_id": "our_uuid", "with_automatic_settings": True, }, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": [ "media", "share", @@ -721,7 +731,9 @@ async def test_initiate_backup_with_agent_error( "instance_id": "unknown_uuid", "with_automatic_settings": True, }, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": [ "media", "share", @@ -747,7 +759,9 @@ async def test_initiate_backup_with_agent_error( "instance_id": "our_uuid", "with_automatic_settings": True, }, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": [ "media", "share", @@ -852,7 +866,9 @@ async def test_initiate_backup_with_agent_error( "database_included": True, "date": ANY, "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, + "failed_addons": [], "failed_agent_ids": ["test.remote"], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", @@ -885,7 +901,9 @@ async def test_initiate_backup_with_agent_error( assert hass_storage[DOMAIN]["data"]["backups"] == [ { "backup_id": "abc123", + "failed_addons": [], "failed_agent_ids": ["test.remote"], + "failed_folders": [], } ] @@ -962,6 +980,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( "automatic_agents", "create_backup_command", + "create_backup_addon_errors", + "create_backup_folder_errors", "create_backup_side_effect", "upload_side_effect", "create_backup_result", @@ -972,6 +992,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, + {}, + {}, None, None, True, @@ -980,6 +1002,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, + {}, + {}, None, None, True, @@ -989,6 +1013,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote", "test.unknown"], {"type": "backup/generate", "agent_ids": ["test.remote", "test.unknown"]}, + {}, + {}, None, None, True, @@ -1005,6 +1031,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote", "test.unknown"], {"type": "backup/generate_with_automatic_settings"}, + {}, + {}, None, None, True, @@ -1026,6 +1054,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, + {}, + {}, Exception("Boom!"), None, False, @@ -1034,6 +1064,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, + {}, + {}, Exception("Boom!"), None, False, @@ -1048,6 +1080,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, + {}, + {}, delayed_boom, None, True, @@ -1056,6 +1090,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, + {}, + {}, delayed_boom, None, True, @@ -1070,6 +1106,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, + {}, + {}, None, Exception("Boom!"), True, @@ -1078,6 +1116,8 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ( ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, + {}, + {}, None, Exception("Boom!"), True, @@ -1088,6 +1128,163 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: } }, ), + # Add-ons can't be backed up + ( + ["test.remote"], + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + { + "test_addon": AddonErrorData( + addon=AddonInfo(name="Test Add-on", slug="test", version="0.0"), + errors=[("test_error", "Boom!")], + ) + }, + {}, + None, + None, + True, + {}, + ), + ( + ["test.remote"], + {"type": "backup/generate_with_automatic_settings"}, + { + "test_addon": AddonErrorData( + addon=AddonInfo(name="Test Add-on", slug="test", version="0.0"), + errors=[("test_error", "Boom!")], + ) + }, + {}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_addons", + "translation_placeholders": {"failed_addons": "Test Add-on"}, + } + }, + ), + # Folders can't be backed up + ( + ["test.remote"], + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + {}, + {Folder.MEDIA: [("test_error", "Boom!")]}, + None, + None, + True, + {}, + ), + ( + ["test.remote"], + {"type": "backup/generate_with_automatic_settings"}, + {}, + {Folder.MEDIA: [("test_error", "Boom!")]}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_folders", + "translation_placeholders": {"failed_folders": "media"}, + } + }, + ), + # Add-ons and folders can't be backed up + ( + ["test.remote"], + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + { + "test_addon": AddonErrorData( + addon=AddonInfo(name="Test Add-on", slug="test", version="0.0"), + errors=[("test_error", "Boom!")], + ) + }, + {Folder.MEDIA: [("test_error", "Boom!")]}, + None, + None, + True, + {}, + ), + ( + ["test.remote"], + {"type": "backup/generate_with_automatic_settings"}, + { + "test_addon": AddonErrorData( + addon=AddonInfo(name="Test Add-on", slug="test", version="0.0"), + errors=[("test_error", "Boom!")], + ) + }, + {Folder.MEDIA: [("test_error", "Boom!")]}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_agents_addons_folders", + "translation_placeholders": { + "failed_addons": "Test Add-on", + "failed_agents": "-", + "failed_folders": "media", + }, + }, + }, + ), + # Add-ons and folders can't be backed up, one agent unavailable + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + { + "test_addon": AddonErrorData( + addon=AddonInfo(name="Test Add-on", slug="test", version="0.0"), + errors=[("test_error", "Boom!")], + ) + }, + {Folder.MEDIA: [("test_error", "Boom!")]}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, + }, + ), + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate_with_automatic_settings"}, + { + "test_addon": AddonErrorData( + addon=AddonInfo(name="Test Add-on", slug="test", version="0.0"), + errors=[("test_error", "Boom!")], + ) + }, + {Folder.MEDIA: [("test_error", "Boom!")]}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_agents_addons_folders", + "translation_placeholders": { + "failed_addons": "Test Add-on", + "failed_agents": "test.unknown", + "failed_folders": "media", + }, + }, + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, + }, + ), ], ) async def test_create_backup_failure_raises_issue( @@ -1096,16 +1293,20 @@ async def test_create_backup_failure_raises_issue( create_backup: AsyncMock, automatic_agents: list[str], create_backup_command: dict[str, Any], + create_backup_addon_errors: dict[str, str], + create_backup_folder_errors: dict[Folder, str], create_backup_side_effect: Exception | None, upload_side_effect: Exception | None, create_backup_result: bool, issues_after_create_backup: dict[tuple[str, str], dict[str, Any]], ) -> None: - """Test backup issue is cleared after backup is created.""" + """Test issue is created when create backup has error.""" mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) + create_backup.return_value[1].result().addon_errors = create_backup_addon_errors + create_backup.return_value[1].result().folder_errors = create_backup_folder_errors create_backup.side_effect = create_backup_side_effect await ws_client.send_json_auto_id( @@ -1857,7 +2058,9 @@ async def test_receive_backup_busy_manager( # finish the backup backup_task.set_result( WrittenBackup( + addon_errors={}, backup=TEST_BACKUP_ABC123, + folder_errors={}, open_stream=AsyncMock(), release_stream=AsyncMock(), ) @@ -1896,7 +2099,9 @@ async def test_receive_backup_agent_error( "instance_id": "our_uuid", "with_automatic_settings": True, }, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": [ "media", "share", @@ -1916,7 +2121,9 @@ async def test_receive_backup_agent_error( "instance_id": "unknown_uuid", "with_automatic_settings": True, }, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": [ "media", "share", @@ -1942,7 +2149,9 @@ async def test_receive_backup_agent_error( "instance_id": "our_uuid", "with_automatic_settings": True, }, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": [ "media", "share", @@ -2072,7 +2281,9 @@ async def test_receive_backup_agent_error( assert hass_storage[DOMAIN]["data"]["backups"] == [ { "backup_id": "abc123", + "failed_addons": [], "failed_agent_ids": ["test.remote"], + "failed_folders": [], } ] @@ -3387,7 +3598,9 @@ async def test_initiate_backup_per_agent_encryption( "database_included": True, "date": ANY, "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", diff --git a/tests/components/backup/test_onboarding.py b/tests/components/backup/test_onboarding.py index 7dfd57ec60a..51d704b8ba5 100644 --- a/tests/components/backup/test_onboarding.py +++ b/tests/components/backup/test_onboarding.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import ANY, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import backup, onboarding from homeassistant.core import HomeAssistant @@ -124,14 +124,16 @@ async def test_onboarding_backup_info( "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) }, backup_id="abc123", - date="1970-01-01T00:00:00.000Z", database_included=True, + date="1970-01-01T00:00:00.000Z", extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + failed_addons=[], + failed_agent_ids=[], + failed_folders=[], 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( @@ -140,17 +142,19 @@ async def test_onboarding_backup_info( "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) }, backup_id="def456", - date="1980-01-01T00:00:00.000Z", database_included=False, + date="1980-01-01T00:00:00.000Z", extra_metadata={ "instance_id": "unknown_uuid", "with_automatic_settings": True, }, + failed_addons=[], + failed_agent_ids=[], + failed_folders=[], 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, ), } diff --git a/tests/components/backup/test_sensors.py b/tests/components/backup/test_sensors.py index bee61887ea5..7320c037b21 100644 --- a/tests/components/backup/test_sensors.py +++ b/tests/components/backup/test_sensors.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import store from homeassistant.components.backup.const import DOMAIN @@ -104,6 +104,8 @@ async def test_sensor_updates( ) await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.backup_last_attempted_automatic_backup") + assert state.state == "2024-11-11T03:45:00+00:00" state = hass.states.get("sensor.backup_last_successful_automatic_backup") assert state.state == "2024-11-11T03:45:00+00:00" state = hass.states.get("sensor.backup_next_scheduled_automatic_backup") @@ -113,6 +115,8 @@ async def test_sensor_updates( async_fire_time_changed(hass) await hass.async_block_till_done() + state = hass.states.get("sensor.backup_last_attempted_automatic_backup") + assert state.state == "2024-11-13T11:00:00+00:00" state = hass.states.get("sensor.backup_last_successful_automatic_backup") assert state.state == "2024-11-13T11:00:00+00:00" state = hass.states.get("sensor.backup_next_scheduled_automatic_backup") diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index b078dcc2be7..a016ab36f3d 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup.const import DOMAIN from homeassistant.core import HomeAssistant @@ -94,7 +94,15 @@ def mock_delay_save() -> Generator[None]: "backups": [ { "backup_id": "abc123", + "failed_addons": [ + { + "name": "Test add-on", + "slug": "test_addon", + "version": "1.0.0", + } + ], "failed_agent_ids": ["test.remote"], + "failed_folders": ["ssl"], } ], "config": { @@ -243,6 +251,57 @@ def mock_delay_save() -> Generator[None]: "minor_version": 6, "version": 1, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_addons": [ + { + "name": "Test add-on", + "slug": "test_addon", + "version": "1.0.0", + } + ], + "failed_agent_ids": ["test.remote"], + "failed_folders": ["ssl"], + } + ], + "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": 7, + "version": 1, + }, ], ) async def test_store_migration( diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index a999672e7f6..af37a3b88a6 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -112,6 +112,11 @@ from tests.common import get_fixture_path ), ), ], + ids=[ + "no addons and no metadata", + "with addons and metadata", + "only metadata", + ], ) def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -> None: """Test reading a backup.""" @@ -167,17 +172,37 @@ def test_validate_password_no_homeassistant() -> None: assert validate_password(mock_path, "hunter2") is False -async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("addons", "padding_size", "decrypted_backup"), + [ + ( + [ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], + 40960, # 4 x 10240 byte of padding + "test_backups/c0cb53bd.tar.decrypted", + ), + ( + [ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + ], + 30720, # 3 x 10240 byte of padding + "test_backups/c0cb53bd.tar.decrypted_skip_core2", + ), + ], +) +async def test_decrypted_backup_streamer( + hass: HomeAssistant, + addons: list[AddonInfo], + padding_size: int, + decrypted_backup: str, +) -> None: """Test the decrypted backup streamer.""" - decrypted_backup_path = get_fixture_path( - "test_backups/c0cb53bd.tar.decrypted", DOMAIN - ) + decrypted_backup_path = get_fixture_path(decrypted_backup, DOMAIN) encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=[ - AddonInfo(name="Core 1", slug="core1", version="1.0.0"), - AddonInfo(name="Core 2", slug="core2", version="1.0.0"), - ], + addons=addons, backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -189,7 +214,7 @@ async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: protected=True, size=encrypted_backup_path.stat().st_size, ) - expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + expected_padding = b"\0" * padding_size async def send_backup() -> AsyncIterator[bytes]: f = encrypted_backup_path.open("rb") @@ -325,17 +350,39 @@ async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> assert isinstance(decryptor._workers[0].error, securetar.SecureTarReadError) -async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("addons", "padding_size", "encrypted_backup"), + [ + ( + [ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], + 40960, # 4 x 10240 byte of padding + "test_backups/c0cb53bd.tar", + ), + ( + [ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + ], + 30720, # 3 x 10240 byte of padding + "test_backups/c0cb53bd.tar.encrypted_skip_core2", + ), + ], +) +async def test_encrypted_backup_streamer( + hass: HomeAssistant, + addons: list[AddonInfo], + padding_size: int, + encrypted_backup: str, +) -> None: """Test the encrypted backup streamer.""" decrypted_backup_path = get_fixture_path( "test_backups/c0cb53bd.tar.decrypted", DOMAIN ) - encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + encrypted_backup_path = get_fixture_path(encrypted_backup, DOMAIN) backup = AgentBackup( - addons=[ - AddonInfo(name="Core 1", slug="core1", version="1.0.0"), - AddonInfo(name="Core 2", slug="core2", version="1.0.0"), - ], + addons=addons, backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -347,7 +394,7 @@ async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: protected=False, size=decrypted_backup_path.stat().st_size, ) - expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + expected_padding = b"\0" * padding_size async def send_backup() -> AsyncIterator[bytes]: f = decrypted_backup_path.open("rb") diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index e6a59142ca2..34e562ecfd6 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -7,7 +7,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import ( AddonInfo, @@ -87,14 +87,16 @@ 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, + date="1970-01-01T00:00:00.000Z", extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + failed_addons=[], + failed_agent_ids=[], + failed_folders=[], folders=[Folder.MEDIA, Folder.SHARE], homeassistant_included=True, homeassistant_version="2024.12.0", name="Test", - failed_agent_ids=[], with_automatic_settings=True, ) @@ -326,7 +328,15 @@ async def test_delete( "backups": [ { "backup_id": "abc123", + "failed_addons": [ + { + "name": "Test add-on", + "slug": "test_addon", + "version": "1.0.0", + } + ], "failed_agent_ids": ["test.remote"], + "failed_folders": ["ssl"], } ] }, diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 18639b0c9be..7678a97305e 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -66,6 +66,7 @@ def client_fixture() -> Generator[MagicMock]: client.heat_state = 2 client.lights = [] client.pumps = [] + client.temperature_range.client = client client.temperature_range.state = LowHighRange.LOW client.fault = None diff --git a/tests/components/balboa/snapshots/test_binary_sensor.ambr b/tests/components/balboa/snapshots/test_binary_sensor.ambr index 4aa0f1d71fe..51f1dfa8e3f 100644 --- a/tests/components/balboa/snapshots/test_binary_sensor.ambr +++ b/tests/components/balboa/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Circulation pump', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'circ_pump', 'unique_id': 'FakeSpa-Circ Pump-c0ffee', @@ -75,6 +76,7 @@ 'original_name': 'Filter cycle 1', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_1', 'unique_id': 'FakeSpa-Filter1-c0ffee', @@ -123,6 +125,7 @@ 'original_name': 'Filter cycle 2', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_2', 'unique_id': 'FakeSpa-Filter2-c0ffee', diff --git a/tests/components/balboa/snapshots/test_climate.ambr b/tests/components/balboa/snapshots/test_climate.ambr index 70e33c4065f..b616c77de7d 100644 --- a/tests/components/balboa/snapshots/test_climate.ambr +++ b/tests/components/balboa/snapshots/test_climate.ambr @@ -38,6 +38,7 @@ 'original_name': None, 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'balboa', 'unique_id': 'FakeSpa-Climate-c0ffee', diff --git a/tests/components/balboa/snapshots/test_event.ambr b/tests/components/balboa/snapshots/test_event.ambr index fc8f591a9fc..2a9b5540101 100644 --- a/tests/components/balboa/snapshots/test_event.ambr +++ b/tests/components/balboa/snapshots/test_event.ambr @@ -48,6 +48,7 @@ 'original_name': 'Fault', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fault', 'unique_id': 'FakeSpa-fault-c0ffee', diff --git a/tests/components/balboa/snapshots/test_fan.ambr b/tests/components/balboa/snapshots/test_fan.ambr index 4df73c3178c..e4d619dc536 100644 --- a/tests/components/balboa/snapshots/test_fan.ambr +++ b/tests/components/balboa/snapshots/test_fan.ambr @@ -29,6 +29,7 @@ 'original_name': 'Pump 1', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'pump', 'unique_id': 'FakeSpa-Pump 1-c0ffee', diff --git a/tests/components/balboa/snapshots/test_light.ambr b/tests/components/balboa/snapshots/test_light.ambr index fdfd7af1d0c..af4b4f973e7 100644 --- a/tests/components/balboa/snapshots/test_light.ambr +++ b/tests/components/balboa/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': 'Light', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'only_light', 'unique_id': 'FakeSpa-Light-c0ffee', diff --git a/tests/components/balboa/snapshots/test_select.ambr b/tests/components/balboa/snapshots/test_select.ambr index 68368bf3602..ae0aafa449e 100644 --- a/tests/components/balboa/snapshots/test_select.ambr +++ b/tests/components/balboa/snapshots/test_select.ambr @@ -32,6 +32,7 @@ 'original_name': 'Temperature range', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_range', 'unique_id': 'FakeSpa-TempHiLow-c0ffee', diff --git a/tests/components/balboa/snapshots/test_switch.ambr b/tests/components/balboa/snapshots/test_switch.ambr index ad63fcdf387..886e07f64bf 100644 --- a/tests/components/balboa/snapshots/test_switch.ambr +++ b/tests/components/balboa/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Filter cycle 2 enabled', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_cycle_2_enabled', 'unique_id': 'FakeSpa-filter_cycle_2_enabled-c0ffee', diff --git a/tests/components/balboa/snapshots/test_time.ambr b/tests/components/balboa/snapshots/test_time.ambr index 6b27717e2d3..2d1f9c42e95 100644 --- a/tests/components/balboa/snapshots/test_time.ambr +++ b/tests/components/balboa/snapshots/test_time.ambr @@ -27,6 +27,7 @@ 'original_name': 'Filter cycle 1 end', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_cycle_end', 'unique_id': 'FakeSpa-filter_cycle_1_end-c0ffee', @@ -74,6 +75,7 @@ 'original_name': 'Filter cycle 1 start', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_cycle_start', 'unique_id': 'FakeSpa-filter_cycle_1_start-c0ffee', @@ -121,6 +123,7 @@ 'original_name': 'Filter cycle 2 end', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_cycle_end', 'unique_id': 'FakeSpa-filter_cycle_2_end-c0ffee', @@ -168,6 +171,7 @@ 'original_name': 'Filter cycle 2 start', 'platform': 'balboa', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_cycle_start', 'unique_id': 'FakeSpa-filter_cycle_2_start-c0ffee', diff --git a/tests/components/balboa/test_binary_sensor.py b/tests/components/balboa/test_binary_sensor.py index 5990c73bb68..8f3c7a4b21c 100644 --- a/tests/components/balboa/test_binary_sensor.py +++ b/tests/components/balboa/test_binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 9c23833518e..5cd5bc9091a 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from pybalboa import SpaControl from pybalboa.enums import HeatMode, OffLowMediumHighState import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, diff --git a/tests/components/balboa/test_event.py b/tests/components/balboa/test_event.py index 04f25f6cfa0..b5a10192c5c 100644 --- a/tests/components/balboa/test_event.py +++ b/tests/components/balboa/test_event.py @@ -6,7 +6,7 @@ from datetime import datetime from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.event import ATTR_EVENT_TYPE from homeassistant.const import STATE_UNKNOWN, Platform diff --git a/tests/components/balboa/test_fan.py b/tests/components/balboa/test_fan.py index 3eacb0d08c0..f9ab201b925 100644 --- a/tests/components/balboa/test_fan.py +++ b/tests/components/balboa/test_fan.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from pybalboa import SpaControl from pybalboa.enums import OffLowHighState, UnknownState import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ATTR_PERCENTAGE from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform diff --git a/tests/components/balboa/test_light.py b/tests/components/balboa/test_light.py index 01469416da5..5eb802f6fc9 100644 --- a/tests/components/balboa/test_light.py +++ b/tests/components/balboa/test_light.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from pybalboa import SpaControl from pybalboa.enums import OffOnState, UnknownState import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/balboa/test_select.py b/tests/components/balboa/test_select.py index da57ee8f22e..e44962b43b9 100644 --- a/tests/components/balboa/test_select.py +++ b/tests/components/balboa/test_select.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, call, patch from pybalboa import SpaControl from pybalboa.enums import LowHighRange import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, diff --git a/tests/components/balboa/test_switch.py b/tests/components/balboa/test_switch.py index 4b6bae172f4..ed031bebe05 100644 --- a/tests/components/balboa/test_switch.py +++ b/tests/components/balboa/test_switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/balboa/test_time.py b/tests/components/balboa/test_time.py index 21778d08e2d..093e741bbf4 100644 --- a/tests/components/balboa/test_time.py +++ b/tests/components/balboa/test_time.py @@ -6,7 +6,7 @@ from datetime import time from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.time import ( ATTR_TIME, diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 700d085dd11..c7915968cbf 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -76,16 +76,17 @@ def mock_config_entry_core() -> MockConfigEntry: ) -@pytest.fixture -async def mock_media_player( +@pytest.fixture(name="integration") +async def integration_fixture( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Mock media_player entity.""" + """Set up the Bang & Olufsen integration.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() @pytest.fixture diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py index a9415a222a8..efa5a0a8680 100644 --- a/tests/components/bang_olufsen/test_diagnostics.py +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -1,8 +1,6 @@ """Test bang_olufsen config entry diagnostics.""" -from unittest.mock import AsyncMock - -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant @@ -19,13 +17,11 @@ async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, entity_registry: EntityRegistry, hass_client: ClientSessionGenerator, + integration: None, mock_config_entry: MockConfigEntry, - mock_mozart_client: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) # Enable an Event entity entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None) diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index 855dab40db1..11f337b715f 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -23,17 +23,12 @@ from tests.common import MockConfigEntry async def test_button_event_creation( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_mozart_client: AsyncMock, + integration: None, entity_registry: EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test button event entities are created.""" - # Load entry - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - # Add Button Event entity ids entity_ids = [ f"event.beosound_balance_11111111_{underscore(button_type)}".replace( @@ -77,14 +72,12 @@ async def test_button_event_creation_beoconnect_core( async def test_button( hass: HomeAssistant, + integration: None, mock_config_entry: MockConfigEntry, mock_mozart_client: AsyncMock, entity_registry: EntityRegistry, ) -> None: """Test button event entity.""" - # Load entry - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) # Enable the entity entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index a389f9fa818..33719cb2311 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -190,14 +190,11 @@ async def test_async_update_sources_outdated_api( async def test_async_update_sources_remote( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test _async_update_sources is called when there are new video sources.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - notification_callback = mock_mozart_client.get_notification_notifications.call_args[ 0 ][0] @@ -246,14 +243,10 @@ async def test_async_update_sources_availability( async def test_async_update_playback_metadata( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test _async_update_playback_metadata.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - playback_metadata_callback = ( mock_mozart_client.get_playback_metadata_notifications.call_args[0][0] ) @@ -286,14 +279,10 @@ async def test_async_update_playback_metadata( async def test_async_update_playback_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test _async_update_playback_error.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - playback_error_callback = ( mock_mozart_client.get_playback_error_notifications.call_args[0][0] ) @@ -309,14 +298,10 @@ async def test_async_update_playback_error( async def test_async_update_playback_progress( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test _async_update_playback_progress.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - playback_progress_callback = ( mock_mozart_client.get_playback_progress_notifications.call_args[0][0] ) @@ -337,14 +322,10 @@ async def test_async_update_playback_progress( async def test_async_update_playback_state( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test _async_update_playback_state.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - playback_state_callback = ( mock_mozart_client.get_playback_state_notifications.call_args[0][0] ) @@ -386,18 +367,14 @@ async def test_async_update_playback_state( ) async def test_async_update_source_change( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, source: Source, content_type: MediaType, progress: int, metadata: PlaybackContentMetadata, ) -> None: """Test _async_update_source_change.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - playback_progress_callback = ( mock_mozart_client.get_playback_progress_notifications.call_args[0][0] ) @@ -427,14 +404,11 @@ async def test_async_update_source_change( async def test_async_turn_off( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_turn_off.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - playback_state_callback = ( mock_mozart_client.get_playback_state_notifications.call_args[0][0] ) @@ -458,14 +432,10 @@ async def test_async_turn_off( async def test_async_set_volume_level( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_set_volume_level and _async_update_volume by proxy.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - volume_callback = mock_mozart_client.get_volume_notifications.call_args[0][0] assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) @@ -526,15 +496,11 @@ async def test_async_update_beolink_line_in( async def test_async_update_beolink_listener( hass: HomeAssistant, snapshot: SnapshotAssertion, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, mock_config_entry_core: MockConfigEntry, ) -> None: """Test _async_update_beolink as a listener.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - playback_metadata_callback = ( mock_mozart_client.get_playback_metadata_notifications.call_args[0][0] ) @@ -612,14 +578,10 @@ async def test_async_update_name_and_beolink( async def test_async_mute_volume( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_mute_volume.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - volume_callback = mock_mozart_client.get_volume_notifications.call_args[0][0] assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) @@ -660,16 +622,12 @@ async def test_async_mute_volume( ) async def test_async_media_play_pause( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, initial_state: RenderingState, command: str, ) -> None: """Test async_media_play_pause.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - playback_state_callback = ( mock_mozart_client.get_playback_state_notifications.call_args[0][0] ) @@ -693,14 +651,10 @@ async def test_async_media_play_pause( async def test_async_media_stop( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_media_stop.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - playback_state_callback = ( mock_mozart_client.get_playback_state_notifications.call_args[0][0] ) @@ -725,14 +679,10 @@ async def test_async_media_stop( async def test_async_media_next_track( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_media_next_track.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, @@ -756,17 +706,13 @@ async def test_async_media_next_track( ) async def test_async_media_seek( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, source: Source, expected_result: AbstractContextManager, seek_called_times: int, ) -> None: """Test async_media_seek.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - source_change_callback = ( mock_mozart_client.get_source_change_notifications.call_args[0][0] ) @@ -791,14 +737,10 @@ async def test_async_media_seek( async def test_async_media_previous_track( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_media_previous_track.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, @@ -811,14 +753,10 @@ async def test_async_media_previous_track( async def test_async_clear_playlist( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_clear_playlist.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, @@ -842,18 +780,14 @@ async def test_async_clear_playlist( ) async def test_async_select_source( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, source: str, expected_result: AbstractContextManager, audio_source_call: int, video_source_call: int, ) -> None: """Test async_select_source with an invalid source.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - with expected_result: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -871,14 +805,10 @@ async def test_async_select_source( async def test_async_select_sound_mode( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_select_sound_mode.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes[ATTR_SOUND_MODE] == TEST_ACTIVE_SOUND_MODE_NAME @@ -908,14 +838,10 @@ async def test_async_select_sound_mode( async def test_async_select_sound_mode_invalid( hass: HomeAssistant, - mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, + integration: None, ) -> None: """Test async_select_sound_mode with an invalid sound_mode.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -934,14 +860,10 @@ async def test_async_select_sound_mode_invalid( async def test_async_play_media_invalid_type( hass: HomeAssistant, - mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, + integration: None, ) -> None: """Test async_play_media only accepts valid media types.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -961,14 +883,10 @@ async def test_async_play_media_invalid_type( async def test_async_play_media_url( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media URL.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - # Setup media source await async_setup_component(hass, "media_source", {"media_source": {}}) @@ -988,14 +906,11 @@ async def test_async_play_media_url( async def test_async_play_media_overlay_absolute_volume_uri( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media overlay with Home Assistant local URI and absolute volume.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await async_setup_component(hass, "media_source", {"media_source": {}}) await hass.services.async_call( @@ -1022,14 +937,10 @@ async def test_async_play_media_overlay_absolute_volume_uri( async def test_async_play_media_overlay_invalid_offset_volume_tts( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media with Home Assistant invalid offset volume and B&O tts.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -1054,14 +965,10 @@ async def test_async_play_media_overlay_invalid_offset_volume_tts( async def test_async_play_media_overlay_offset_volume_tts( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media with Home Assistant invalid offset volume and B&O tts.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - volume_callback = mock_mozart_client.get_volume_notifications.call_args[0][0] # Set the volume to enable offset @@ -1087,14 +994,10 @@ async def test_async_play_media_overlay_offset_volume_tts( async def test_async_play_media_tts( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media with Home Assistant tts.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await async_setup_component(hass, "media_source", {"media_source": {}}) await hass.services.async_call( @@ -1113,14 +1016,10 @@ async def test_async_play_media_tts( async def test_async_play_media_radio( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media with B&O radio.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -1139,14 +1038,10 @@ async def test_async_play_media_radio( async def test_async_play_media_favourite( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media with B&O favourite.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -1163,14 +1058,11 @@ async def test_async_play_media_favourite( async def test_async_play_media_deezer_flow( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media with Deezer flow.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - # Send a service call await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -1191,14 +1083,10 @@ async def test_async_play_media_deezer_flow( async def test_async_play_media_deezer_playlist( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media with Deezer playlist.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -1218,14 +1106,10 @@ async def test_async_play_media_deezer_playlist( async def test_async_play_media_deezer_track( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media with Deezer track.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -1244,16 +1128,13 @@ async def test_async_play_media_deezer_track( async def test_async_play_media_invalid_deezer( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media with an invalid/no Deezer login.""" mock_mozart_client.start_deezer_flow.side_effect = TEST_DEEZER_INVALID_FLOW - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -1275,14 +1156,10 @@ async def test_async_play_media_invalid_deezer( async def test_async_play_media_url_m3u( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_play_media URL with the m3u extension.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await async_setup_component(hass, "media_source", {"media_source": {}}) with ( @@ -1349,16 +1226,12 @@ async def test_async_play_media_url_m3u( async def test_async_browse_media( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, + integration: None, child: dict[str, str | bool | None], present: bool, ) -> None: """Test async_browse_media with audio and video source.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await async_setup_component(hass, "media_source", {"media_source": {}}) client = await hass_ws_client() @@ -1386,18 +1259,14 @@ async def test_async_browse_media( async def test_async_join_players( hass: HomeAssistant, snapshot: SnapshotAssertion, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, mock_config_entry_core: MockConfigEntry, group_members: list[str], expand_count: int, join_count: int, ) -> None: """Test async_join_players.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - source_change_callback = ( mock_mozart_client.get_source_change_notifications.call_args[0][0] ) @@ -1453,8 +1322,8 @@ async def test_async_join_players( async def test_async_join_players_invalid( hass: HomeAssistant, snapshot: SnapshotAssertion, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, mock_config_entry_core: MockConfigEntry, source: Source, group_members: list[str], @@ -1462,10 +1331,6 @@ async def test_async_join_players_invalid( error_type: str, ) -> None: """Test async_join_players with an invalid media_player entity.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - source_change_callback = ( mock_mozart_client.get_source_change_notifications.call_args[0][0] ) @@ -1505,14 +1370,10 @@ async def test_async_join_players_invalid( async def test_async_unjoin_player( hass: HomeAssistant, snapshot: SnapshotAssertion, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_unjoin_player.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_UNJOIN, @@ -1552,16 +1413,12 @@ async def test_async_unjoin_player( async def test_async_beolink_join( hass: HomeAssistant, snapshot: SnapshotAssertion, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, service_parameters: dict[str, str], method_parameters: dict[str, str], ) -> None: """Test async_beolink_join with defined JID and JID and source.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( DOMAIN, "beolink_join", @@ -1601,16 +1458,12 @@ async def test_async_beolink_join( async def test_async_beolink_join_invalid( hass: HomeAssistant, snapshot: SnapshotAssertion, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, service_parameters: dict[str, str], expected_result: AbstractContextManager, ) -> None: """Test invalid async_beolink_join calls with defined JID or source ID.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - with expected_result: await hass.services.async_call( DOMAIN, @@ -1665,8 +1518,8 @@ async def test_async_beolink_expand( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, parameter: str, parameter_value: bool | list[str], expand_side_effect: NotFoundException | None, @@ -1676,9 +1529,6 @@ async def test_async_beolink_expand( """Test async_beolink_expand.""" mock_mozart_client.post_beolink_expand.side_effect = expand_side_effect - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - source_change_callback = ( mock_mozart_client.get_source_change_notifications.call_args[0][0] ) @@ -1714,14 +1564,10 @@ async def test_async_beolink_expand( async def test_async_beolink_unexpand( hass: HomeAssistant, snapshot: SnapshotAssertion, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test test_async_beolink_unexpand.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( DOMAIN, "beolink_unexpand", @@ -1741,14 +1587,10 @@ async def test_async_beolink_unexpand( async def test_async_beolink_allstandby( hass: HomeAssistant, snapshot: SnapshotAssertion, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test async_beolink_allstandby.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.services.async_call( DOMAIN, "beolink_allstandby", @@ -1775,13 +1617,11 @@ async def test_async_beolink_allstandby( ) async def test_async_set_repeat( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, repeat: RepeatMode, ) -> None: """Test async_set_repeat.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ATTR_MEDIA_REPEAT not in states.attributes @@ -1822,14 +1662,11 @@ async def test_async_set_repeat( ) async def test_async_set_shuffle( hass: HomeAssistant, + integration: None, mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, shuffle: bool, ) -> None: """Test async_set_shuffle.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ATTR_MEDIA_SHUFFLE not in states.attributes diff --git a/tests/components/bang_olufsen/test_websocket.py b/tests/components/bang_olufsen/test_websocket.py index ecf5b2d011e..3b812846b7c 100644 --- a/tests/components/bang_olufsen/test_websocket.py +++ b/tests/components/bang_olufsen/test_websocket.py @@ -23,16 +23,13 @@ from tests.common import MockConfigEntry async def test_connection( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mock_config_entry: MockConfigEntry, + integration: None, mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test on_connection and on_connection_lost logs and calls correctly.""" - mock_mozart_client.websocket_connected = True - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - connection_callback = mock_mozart_client.get_on_connection.call_args[0][0] caplog.set_level(logging.DEBUG) @@ -56,14 +53,11 @@ async def test_connection( async def test_connection_lost( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mock_config_entry: MockConfigEntry, + integration: None, mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test on_connection_lost logs and calls correctly.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - connection_lost_callback = mock_mozart_client.get_on_connection_lost.call_args[0][0] mock_connection_lost_callback = Mock() @@ -84,14 +78,11 @@ async def test_connection_lost( async def test_on_software_update_state( hass: HomeAssistant, device_registry: DeviceRegistry, - mock_config_entry: MockConfigEntry, + integration: None, mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test software version is updated through on_software_update_state.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - software_update_state_callback = ( mock_mozart_client.get_software_update_state_notifications.call_args[0][0] ) @@ -114,14 +105,11 @@ async def test_on_all_notifications_raw( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_registry: DeviceRegistry, - mock_config_entry: MockConfigEntry, + integration: None, mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test on_all_notifications_raw logs and fires as expected.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - all_notifications_raw_callback = ( mock_mozart_client.get_all_notifications_raw.call_args[0][0] ) diff --git a/tests/components/blebox/test_climate.py b/tests/components/blebox/test_climate.py index e402a3d5fbd..9da2d9a8a68 100644 --- a/tests/components/blebox/test_climate.py +++ b/tests/components/blebox/test_climate.py @@ -93,7 +93,7 @@ async def test_init( supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] assert supported_features & ClimateEntityFeature.TARGET_TEMPERATURE - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, None] + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF] assert ATTR_DEVICE_CLASS not in state.attributes assert ATTR_HVAC_MODE not in state.attributes diff --git a/tests/components/blink/test_diagnostics.py b/tests/components/blink/test_diagnostics.py index d527633d4c9..334ecfaa50c 100644 --- a/tests/components/blink/test_diagnostics.py +++ b/tests/components/blink/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/blue_current/snapshots/test_button.ambr b/tests/components/blue_current/snapshots/test_button.ambr new file mode 100644 index 00000000000..36a043630ea --- /dev/null +++ b/tests/components/blue_current/snapshots/test_button.ambr @@ -0,0 +1,147 @@ +# serializer version: 1 +# name: test_buttons_created[button.101_reboot-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.101_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'blue_current', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot', + 'unique_id': 'reboot_101', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons_created[button.101_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': '101 Reboot', + }), + 'context': , + 'entity_id': 'button.101_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons_created[button.101_reset-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.101_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reset', + 'platform': 'blue_current', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset', + 'unique_id': 'reset_101', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons_created[button.101_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': '101 Reset', + }), + 'context': , + 'entity_id': 'button.101_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons_created[button.101_stop_charge_session-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.101_stop_charge_session', + 'has_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 session', + 'platform': 'blue_current', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge_session', + 'unique_id': 'stop_charge_session_101', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons_created[button.101_stop_charge_session-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '101 Stop charge session', + }), + 'context': , + 'entity_id': 'button.101_stop_charge_session', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/blue_current/test_button.py b/tests/components/blue_current/test_button.py new file mode 100644 index 00000000000..7b9e7a7e7ce --- /dev/null +++ b/tests/components/blue_current/test_button.py @@ -0,0 +1,51 @@ +"""The tests for Blue Current buttons.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + +charge_point_buttons = ["stop_charge_session", "reset", "reboot"] + + +async def test_buttons_created( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test if all buttons are created.""" + await init_integration(hass, config_entry, Platform.BUTTON) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_charge_point_buttons( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test the underlying charge point buttons.""" + await init_integration(hass, config_entry, Platform.BUTTON) + + for button in charge_point_buttons: + state = hass.states.get(f"button.101_{button}") + assert state is not None + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.101_{button}"}, + blocking=True, + ) + + state = hass.states.get(f"button.101_{button}") + assert state + assert state.state == "2023-01-13T12:00:00+00:00" diff --git a/tests/components/bluemaestro/snapshots/test_sensor.ambr b/tests/components/bluemaestro/snapshots/test_sensor.ambr index 48f20aa97b5..055ceb2731f 100644 --- a/tests/components/bluemaestro/snapshots/test_sensor.ambr +++ b/tests/components/bluemaestro/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'bluemaestro', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff-battery', @@ -75,12 +76,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Dew point', 'platform': 'bluemaestro', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dew_point', 'unique_id': 'aa:bb:cc:dd:ee:ff-dew_point', @@ -133,6 +138,7 @@ 'original_name': 'Humidity', 'platform': 'bluemaestro', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff-humidity', @@ -185,6 +191,7 @@ 'original_name': 'Signal strength', 'platform': 'bluemaestro', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff-signal_strength', @@ -231,12 +238,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'bluemaestro', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff-temperature', diff --git a/tests/components/bluemaestro/test_sensor.py b/tests/components/bluemaestro/test_sensor.py index a75e390c781..40e8550cc9e 100644 --- a/tests/components/bluemaestro/test_sensor.py +++ b/tests/components/bluemaestro/test_sensor.py @@ -1,7 +1,7 @@ """Test the BlueMaestro sensors.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.bluemaestro.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py index 94036d208ab..c61be9e2b32 100644 --- a/tests/components/blueprint/test_importer.py +++ b/tests/components/blueprint/test_importer.py @@ -4,7 +4,7 @@ import json from pathlib import Path import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.blueprint import importer from homeassistant.core import HomeAssistant diff --git a/tests/components/bluesound/test_button.py b/tests/components/bluesound/test_button.py new file mode 100644 index 00000000000..0cb40f53d27 --- /dev/null +++ b/tests/components/bluesound/test_button.py @@ -0,0 +1,47 @@ +"""Test for bluesound buttons.""" + +from unittest.mock import call + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .conftest import PlayerMocks + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_sleep_timer( + hass: HomeAssistant, + player_mocks: PlayerMocks, + setup_config_entry: None, +) -> None: + """Test the media player volume set.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.player_name1111_set_sleep_timer"}, + blocking=True, + ) + + player_mocks.player_data.player.sleep_timer.assert_called_once() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_clear_sleep_timer( + hass: HomeAssistant, + player_mocks: PlayerMocks, + setup_config_entry: None, +) -> None: + """Test the media player volume set.""" + player_mocks.player_data.player.sleep_timer.side_effect = [15, 30, 45, 60, 90, 0] + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.player_name1111_clear_sleep_timer"}, + blocking=True, + ) + + player_mocks.player_data.player.sleep_timer.assert_has_calls([call()] * 6) diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 80fca88b2de..540bf1bfbd1 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -655,6 +655,7 @@ async def test_diagnostics_remote_adapter( "source": "esp32", "start_time": ANY, "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, + "raw_advertisement_data": {"44:44:33:11:23:45": None}, "type": "FakeScanner", }, ], diff --git a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr index 569d39c1a5a..3a7cdd86be1 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charging status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_status', 'unique_id': 'WBY00000000REXI01-charging_status', @@ -75,6 +76,7 @@ 'original_name': 'Check control messages', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'check_control_messages', 'unique_id': 'WBY00000000REXI01-check_control_messages', @@ -120,9 +122,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Condition based services', + 'original_name': 'Condition-based services', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_based_services', 'unique_id': 'WBY00000000REXI01-condition_based_services', @@ -135,7 +138,7 @@ 'brake_fluid': 'OK', 'brake_fluid_date': '2022-10-01', 'device_class': 'problem', - 'friendly_name': 'i3 (+ REX) Condition based services', + 'friendly_name': 'i3 (+ REX) Condition-based services', 'vehicle_check': 'OK', 'vehicle_check_date': '2023-05-01', 'vehicle_tuv': 'OK', @@ -177,6 +180,7 @@ 'original_name': 'Connection status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connection_status', 'unique_id': 'WBY00000000REXI01-connection_status', @@ -225,6 +229,7 @@ 'original_name': 'Door lock state', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'door_lock_state', 'unique_id': 'WBY00000000REXI01-door_lock_state', @@ -274,6 +279,7 @@ 'original_name': 'Lids', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lids', 'unique_id': 'WBY00000000REXI01-lids', @@ -326,9 +332,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pre entry climatization', + 'original_name': 'Pre-entry climatization', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_pre_entry_climatization_enabled', 'unique_id': 'WBY00000000REXI01-is_pre_entry_climatization_enabled', @@ -338,7 +345,7 @@ # name: test_entity_state_attrs[binary_sensor.i3_rex_pre_entry_climatization-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i3 (+ REX) Pre entry climatization', + 'friendly_name': 'i3 (+ REX) Pre-entry climatization', }), 'context': , 'entity_id': 'binary_sensor.i3_rex_pre_entry_climatization', @@ -376,6 +383,7 @@ 'original_name': 'Windows', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'windows', 'unique_id': 'WBY00000000REXI01-windows', @@ -426,6 +434,7 @@ 'original_name': 'Charging status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_status', 'unique_id': 'WBA00000000DEMO02-charging_status', @@ -474,6 +483,7 @@ 'original_name': 'Check control messages', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'check_control_messages', 'unique_id': 'WBA00000000DEMO02-check_control_messages', @@ -520,9 +530,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Condition based services', + 'original_name': 'Condition-based services', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_based_services', 'unique_id': 'WBA00000000DEMO02-condition_based_services', @@ -536,7 +547,7 @@ 'brake_fluid_date': '2024-12-01', 'brake_fluid_distance': '50000 km', 'device_class': 'problem', - 'friendly_name': 'i4 eDrive40 Condition based services', + 'friendly_name': 'i4 eDrive40 Condition-based services', 'tire_wear_front': 'OK', 'tire_wear_rear': 'OK', 'vehicle_check': 'OK', @@ -582,6 +593,7 @@ 'original_name': 'Connection status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connection_status', 'unique_id': 'WBA00000000DEMO02-connection_status', @@ -630,6 +642,7 @@ 'original_name': 'Door lock state', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'door_lock_state', 'unique_id': 'WBA00000000DEMO02-door_lock_state', @@ -679,6 +692,7 @@ 'original_name': 'Lids', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lids', 'unique_id': 'WBA00000000DEMO02-lids', @@ -730,9 +744,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pre entry climatization', + 'original_name': 'Pre-entry climatization', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_pre_entry_climatization_enabled', 'unique_id': 'WBA00000000DEMO02-is_pre_entry_climatization_enabled', @@ -742,7 +757,7 @@ # name: test_entity_state_attrs[binary_sensor.i4_edrive40_pre_entry_climatization-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 Pre entry climatization', + 'friendly_name': 'i4 eDrive40 Pre-entry climatization', }), 'context': , 'entity_id': 'binary_sensor.i4_edrive40_pre_entry_climatization', @@ -780,6 +795,7 @@ 'original_name': 'Windows', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'windows', 'unique_id': 'WBA00000000DEMO02-windows', @@ -833,6 +849,7 @@ 'original_name': 'Charging status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_status', 'unique_id': 'WBA00000000DEMO01-charging_status', @@ -881,6 +898,7 @@ 'original_name': 'Check control messages', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'check_control_messages', 'unique_id': 'WBA00000000DEMO01-check_control_messages', @@ -927,9 +945,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Condition based services', + 'original_name': 'Condition-based services', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_based_services', 'unique_id': 'WBA00000000DEMO01-condition_based_services', @@ -943,7 +962,7 @@ 'brake_fluid_date': '2024-12-01', 'brake_fluid_distance': '50000 km', 'device_class': 'problem', - 'friendly_name': 'iX xDrive50 Condition based services', + 'friendly_name': 'iX xDrive50 Condition-based services', 'tire_wear_front': 'OK', 'tire_wear_rear': 'OK', 'vehicle_check': 'OK', @@ -989,6 +1008,7 @@ 'original_name': 'Connection status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connection_status', 'unique_id': 'WBA00000000DEMO01-connection_status', @@ -1037,6 +1057,7 @@ 'original_name': 'Door lock state', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'door_lock_state', 'unique_id': 'WBA00000000DEMO01-door_lock_state', @@ -1086,6 +1107,7 @@ 'original_name': 'Lids', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lids', 'unique_id': 'WBA00000000DEMO01-lids', @@ -1138,9 +1160,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pre entry climatization', + 'original_name': 'Pre-entry climatization', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_pre_entry_climatization_enabled', 'unique_id': 'WBA00000000DEMO01-is_pre_entry_climatization_enabled', @@ -1150,7 +1173,7 @@ # name: test_entity_state_attrs[binary_sensor.ix_xdrive50_pre_entry_climatization-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 Pre entry climatization', + 'friendly_name': 'iX xDrive50 Pre-entry climatization', }), 'context': , 'entity_id': 'binary_sensor.ix_xdrive50_pre_entry_climatization', @@ -1188,6 +1211,7 @@ 'original_name': 'Windows', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'windows', 'unique_id': 'WBA00000000DEMO01-windows', @@ -1241,6 +1265,7 @@ 'original_name': 'Check control messages', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'check_control_messages', 'unique_id': 'WBA00000000DEMO03-check_control_messages', @@ -1288,9 +1313,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Condition based services', + 'original_name': 'Condition-based services', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'condition_based_services', 'unique_id': 'WBA00000000DEMO03-condition_based_services', @@ -1304,7 +1330,7 @@ 'brake_fluid_date': '2024-12-01', 'brake_fluid_distance': '50000 km', 'device_class': 'problem', - 'friendly_name': 'M340i xDrive Condition based services', + 'friendly_name': 'M340i xDrive Condition-based services', 'oil': 'OK', 'oil_date': '2024-12-01', 'oil_distance': '50000 km', @@ -1353,6 +1379,7 @@ 'original_name': 'Door lock state', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'door_lock_state', 'unique_id': 'WBA00000000DEMO03-door_lock_state', @@ -1402,6 +1429,7 @@ 'original_name': 'Lids', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lids', 'unique_id': 'WBA00000000DEMO03-lids', @@ -1456,6 +1484,7 @@ 'original_name': 'Windows', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'windows', 'unique_id': 'WBA00000000DEMO03-windows', diff --git a/tests/components/bmw_connected_drive/snapshots/test_button.ambr b/tests/components/bmw_connected_drive/snapshots/test_button.ambr index 5072b918d2e..f8946f8c668 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_button.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Activate air conditioning', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activate_air_conditioning', 'unique_id': 'WBY00000000REXI01-activate_air_conditioning', @@ -74,6 +75,7 @@ 'original_name': 'Find vehicle', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'find_vehicle', 'unique_id': 'WBY00000000REXI01-find_vehicle', @@ -121,6 +123,7 @@ 'original_name': 'Flash lights', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_flash', 'unique_id': 'WBY00000000REXI01-light_flash', @@ -168,6 +171,7 @@ 'original_name': 'Sound horn', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sound_horn', 'unique_id': 'WBY00000000REXI01-sound_horn', @@ -215,6 +219,7 @@ 'original_name': 'Activate air conditioning', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activate_air_conditioning', 'unique_id': 'WBA00000000DEMO02-activate_air_conditioning', @@ -262,6 +267,7 @@ 'original_name': 'Deactivate air conditioning', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'deactivate_air_conditioning', 'unique_id': 'WBA00000000DEMO02-deactivate_air_conditioning', @@ -309,6 +315,7 @@ 'original_name': 'Find vehicle', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'find_vehicle', 'unique_id': 'WBA00000000DEMO02-find_vehicle', @@ -356,6 +363,7 @@ 'original_name': 'Flash lights', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_flash', 'unique_id': 'WBA00000000DEMO02-light_flash', @@ -403,6 +411,7 @@ 'original_name': 'Sound horn', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sound_horn', 'unique_id': 'WBA00000000DEMO02-sound_horn', @@ -450,6 +459,7 @@ 'original_name': 'Activate air conditioning', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activate_air_conditioning', 'unique_id': 'WBA00000000DEMO01-activate_air_conditioning', @@ -497,6 +507,7 @@ 'original_name': 'Deactivate air conditioning', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'deactivate_air_conditioning', 'unique_id': 'WBA00000000DEMO01-deactivate_air_conditioning', @@ -544,6 +555,7 @@ 'original_name': 'Find vehicle', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'find_vehicle', 'unique_id': 'WBA00000000DEMO01-find_vehicle', @@ -591,6 +603,7 @@ 'original_name': 'Flash lights', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_flash', 'unique_id': 'WBA00000000DEMO01-light_flash', @@ -638,6 +651,7 @@ 'original_name': 'Sound horn', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sound_horn', 'unique_id': 'WBA00000000DEMO01-sound_horn', @@ -685,6 +699,7 @@ 'original_name': 'Activate air conditioning', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activate_air_conditioning', 'unique_id': 'WBA00000000DEMO03-activate_air_conditioning', @@ -732,6 +747,7 @@ 'original_name': 'Deactivate air conditioning', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'deactivate_air_conditioning', 'unique_id': 'WBA00000000DEMO03-deactivate_air_conditioning', @@ -779,6 +795,7 @@ 'original_name': 'Find vehicle', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'find_vehicle', 'unique_id': 'WBA00000000DEMO03-find_vehicle', @@ -826,6 +843,7 @@ 'original_name': 'Flash lights', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_flash', 'unique_id': 'WBA00000000DEMO03-light_flash', @@ -873,6 +891,7 @@ 'original_name': 'Sound horn', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sound_horn', 'unique_id': 'WBA00000000DEMO03-sound_horn', diff --git a/tests/components/bmw_connected_drive/snapshots/test_lock.ambr b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr index 3dc4e59b7b1..47eee9fdb15 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_lock.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': 'Lock', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock', 'unique_id': 'WBY00000000REXI01-lock', @@ -76,6 +77,7 @@ 'original_name': 'Lock', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock', 'unique_id': 'WBA00000000DEMO02-lock', @@ -125,6 +127,7 @@ 'original_name': 'Lock', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock', 'unique_id': 'WBA00000000DEMO01-lock', @@ -174,6 +177,7 @@ 'original_name': 'Lock', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock', 'unique_id': 'WBA00000000DEMO03-lock', diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr index 866e52e7982..c86ed54197c 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_number.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Target SoC', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'target_soc', 'unique_id': 'WBA00000000DEMO02-target_soc', @@ -89,6 +90,7 @@ 'original_name': 'Target SoC', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'target_soc', 'unique_id': 'WBA00000000DEMO01-target_soc', diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index 0edead03f26..15334fc72b8 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_mode', 'unique_id': 'WBY00000000REXI01-charging_mode', @@ -101,6 +102,7 @@ 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ac_limit', 'unique_id': 'WBA00000000DEMO02-ac_limit', @@ -170,6 +172,7 @@ 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_mode', 'unique_id': 'WBA00000000DEMO02-charging_mode', @@ -238,6 +241,7 @@ 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ac_limit', 'unique_id': 'WBA00000000DEMO01-ac_limit', @@ -307,6 +311,7 @@ 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_mode', 'unique_id': 'WBA00000000DEMO01-charging_mode', diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr index 230025fc865..2f7d2847ad6 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -30,6 +30,7 @@ 'original_name': 'AC current limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ac_current_limit', 'unique_id': 'WBY00000000REXI01-charging_profile.ac_current_limit', @@ -79,6 +80,7 @@ 'original_name': 'Charging end time', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_end_time', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.charging_end_time', @@ -127,6 +129,7 @@ 'original_name': 'Charging start time', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_start_time', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.charging_start_time', @@ -190,6 +193,7 @@ 'original_name': 'Charging status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_status', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.charging_status', @@ -255,6 +259,7 @@ 'original_name': 'Charging target', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_target', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.charging_target', @@ -308,6 +313,7 @@ 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'WBY00000000REXI01-mileage', @@ -363,6 +369,7 @@ 'original_name': 'Remaining battery percent', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_battery_percent', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.remaining_battery_percent', @@ -418,6 +425,7 @@ 'original_name': 'Remaining fuel', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_fuel', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.remaining_fuel', @@ -473,6 +481,7 @@ 'original_name': 'Remaining fuel percent', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_fuel_percent', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.remaining_fuel_percent', @@ -527,6 +536,7 @@ 'original_name': 'Remaining range electric', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_range_electric', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.remaining_range_electric', @@ -582,6 +592,7 @@ 'original_name': 'Remaining range fuel', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_range_fuel', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.remaining_range_fuel', @@ -637,6 +648,7 @@ 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_range_total', 'unique_id': 'WBY00000000REXI01-fuel_and_battery.remaining_range_total', @@ -690,6 +702,7 @@ 'original_name': 'AC current limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ac_current_limit', 'unique_id': 'WBA00000000DEMO02-charging_profile.ac_current_limit', @@ -739,6 +752,7 @@ 'original_name': 'Charging end time', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_end_time', 'unique_id': 'WBA00000000DEMO02-fuel_and_battery.charging_end_time', @@ -787,6 +801,7 @@ 'original_name': 'Charging start time', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_start_time', 'unique_id': 'WBA00000000DEMO02-fuel_and_battery.charging_start_time', @@ -850,6 +865,7 @@ 'original_name': 'Charging status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_status', 'unique_id': 'WBA00000000DEMO02-fuel_and_battery.charging_status', @@ -915,6 +931,7 @@ 'original_name': 'Charging target', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_target', 'unique_id': 'WBA00000000DEMO02-fuel_and_battery.charging_target', @@ -971,6 +988,7 @@ 'original_name': 'Climate status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_status', 'unique_id': 'WBA00000000DEMO02-climate.activity', @@ -1034,6 +1052,7 @@ 'original_name': 'Front left target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_left_target_pressure', 'unique_id': 'WBA00000000DEMO02-tires.front_left.target_pressure', @@ -1092,6 +1111,7 @@ 'original_name': 'Front left tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_left_current_pressure', 'unique_id': 'WBA00000000DEMO02-tires.front_left.current_pressure', @@ -1150,6 +1170,7 @@ 'original_name': 'Front right target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_right_target_pressure', 'unique_id': 'WBA00000000DEMO02-tires.front_right.target_pressure', @@ -1208,6 +1229,7 @@ 'original_name': 'Front right tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_right_current_pressure', 'unique_id': 'WBA00000000DEMO02-tires.front_right.current_pressure', @@ -1263,6 +1285,7 @@ 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'WBA00000000DEMO02-mileage', @@ -1321,6 +1344,7 @@ 'original_name': 'Rear left target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_left_target_pressure', 'unique_id': 'WBA00000000DEMO02-tires.rear_left.target_pressure', @@ -1379,6 +1403,7 @@ 'original_name': 'Rear left tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_left_current_pressure', 'unique_id': 'WBA00000000DEMO02-tires.rear_left.current_pressure', @@ -1437,6 +1462,7 @@ 'original_name': 'Rear right target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_right_target_pressure', 'unique_id': 'WBA00000000DEMO02-tires.rear_right.target_pressure', @@ -1495,6 +1521,7 @@ 'original_name': 'Rear right tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_right_current_pressure', 'unique_id': 'WBA00000000DEMO02-tires.rear_right.current_pressure', @@ -1550,6 +1577,7 @@ 'original_name': 'Remaining battery percent', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_battery_percent', 'unique_id': 'WBA00000000DEMO02-fuel_and_battery.remaining_battery_percent', @@ -1605,6 +1633,7 @@ 'original_name': 'Remaining range electric', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_range_electric', 'unique_id': 'WBA00000000DEMO02-fuel_and_battery.remaining_range_electric', @@ -1660,6 +1689,7 @@ 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_range_total', 'unique_id': 'WBA00000000DEMO02-fuel_and_battery.remaining_range_total', @@ -1713,6 +1743,7 @@ 'original_name': 'AC current limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ac_current_limit', 'unique_id': 'WBA00000000DEMO01-charging_profile.ac_current_limit', @@ -1762,6 +1793,7 @@ 'original_name': 'Charging end time', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_end_time', 'unique_id': 'WBA00000000DEMO01-fuel_and_battery.charging_end_time', @@ -1810,6 +1842,7 @@ 'original_name': 'Charging start time', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_start_time', 'unique_id': 'WBA00000000DEMO01-fuel_and_battery.charging_start_time', @@ -1873,6 +1906,7 @@ 'original_name': 'Charging status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_status', 'unique_id': 'WBA00000000DEMO01-fuel_and_battery.charging_status', @@ -1938,6 +1972,7 @@ 'original_name': 'Charging target', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_target', 'unique_id': 'WBA00000000DEMO01-fuel_and_battery.charging_target', @@ -1994,6 +2029,7 @@ 'original_name': 'Climate status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_status', 'unique_id': 'WBA00000000DEMO01-climate.activity', @@ -2057,6 +2093,7 @@ 'original_name': 'Front left target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_left_target_pressure', 'unique_id': 'WBA00000000DEMO01-tires.front_left.target_pressure', @@ -2115,6 +2152,7 @@ 'original_name': 'Front left tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_left_current_pressure', 'unique_id': 'WBA00000000DEMO01-tires.front_left.current_pressure', @@ -2173,6 +2211,7 @@ 'original_name': 'Front right target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_right_target_pressure', 'unique_id': 'WBA00000000DEMO01-tires.front_right.target_pressure', @@ -2231,6 +2270,7 @@ 'original_name': 'Front right tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_right_current_pressure', 'unique_id': 'WBA00000000DEMO01-tires.front_right.current_pressure', @@ -2286,6 +2326,7 @@ 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'WBA00000000DEMO01-mileage', @@ -2344,6 +2385,7 @@ 'original_name': 'Rear left target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_left_target_pressure', 'unique_id': 'WBA00000000DEMO01-tires.rear_left.target_pressure', @@ -2402,6 +2444,7 @@ 'original_name': 'Rear left tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_left_current_pressure', 'unique_id': 'WBA00000000DEMO01-tires.rear_left.current_pressure', @@ -2460,6 +2503,7 @@ 'original_name': 'Rear right target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_right_target_pressure', 'unique_id': 'WBA00000000DEMO01-tires.rear_right.target_pressure', @@ -2518,6 +2562,7 @@ 'original_name': 'Rear right tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_right_current_pressure', 'unique_id': 'WBA00000000DEMO01-tires.rear_right.current_pressure', @@ -2573,6 +2618,7 @@ 'original_name': 'Remaining battery percent', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_battery_percent', 'unique_id': 'WBA00000000DEMO01-fuel_and_battery.remaining_battery_percent', @@ -2628,6 +2674,7 @@ 'original_name': 'Remaining range electric', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_range_electric', 'unique_id': 'WBA00000000DEMO01-fuel_and_battery.remaining_range_electric', @@ -2683,6 +2730,7 @@ 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_range_total', 'unique_id': 'WBA00000000DEMO01-fuel_and_battery.remaining_range_total', @@ -2741,6 +2789,7 @@ 'original_name': 'Climate status', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_status', 'unique_id': 'WBA00000000DEMO03-climate.activity', @@ -2804,6 +2853,7 @@ 'original_name': 'Front left target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_left_target_pressure', 'unique_id': 'WBA00000000DEMO03-tires.front_left.target_pressure', @@ -2862,6 +2912,7 @@ 'original_name': 'Front left tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_left_current_pressure', 'unique_id': 'WBA00000000DEMO03-tires.front_left.current_pressure', @@ -2920,6 +2971,7 @@ 'original_name': 'Front right target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_right_target_pressure', 'unique_id': 'WBA00000000DEMO03-tires.front_right.target_pressure', @@ -2978,6 +3030,7 @@ 'original_name': 'Front right tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'front_right_current_pressure', 'unique_id': 'WBA00000000DEMO03-tires.front_right.current_pressure', @@ -3033,6 +3086,7 @@ 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'WBA00000000DEMO03-mileage', @@ -3091,6 +3145,7 @@ 'original_name': 'Rear left target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_left_target_pressure', 'unique_id': 'WBA00000000DEMO03-tires.rear_left.target_pressure', @@ -3149,6 +3204,7 @@ 'original_name': 'Rear left tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_left_current_pressure', 'unique_id': 'WBA00000000DEMO03-tires.rear_left.current_pressure', @@ -3207,6 +3263,7 @@ 'original_name': 'Rear right target pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_right_target_pressure', 'unique_id': 'WBA00000000DEMO03-tires.rear_right.target_pressure', @@ -3265,6 +3322,7 @@ 'original_name': 'Rear right tire pressure', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_right_current_pressure', 'unique_id': 'WBA00000000DEMO03-tires.rear_right.current_pressure', @@ -3320,6 +3378,7 @@ 'original_name': 'Remaining fuel', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_fuel', 'unique_id': 'WBA00000000DEMO03-fuel_and_battery.remaining_fuel', @@ -3375,6 +3434,7 @@ 'original_name': 'Remaining fuel percent', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_fuel_percent', 'unique_id': 'WBA00000000DEMO03-fuel_and_battery.remaining_fuel_percent', @@ -3429,6 +3489,7 @@ 'original_name': 'Remaining range fuel', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_range_fuel', 'unique_id': 'WBA00000000DEMO03-fuel_and_battery.remaining_range_fuel', @@ -3484,6 +3545,7 @@ 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_range_total', 'unique_id': 'WBA00000000DEMO03-fuel_and_battery.remaining_range_total', diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr index ce6ebc21f51..afd52e82d90 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Climate', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate', 'unique_id': 'WBA00000000DEMO02-climate', @@ -74,6 +75,7 @@ 'original_name': 'Charging', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging', 'unique_id': 'WBA00000000DEMO01-charging', @@ -121,6 +123,7 @@ 'original_name': 'Climate', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate', 'unique_id': 'WBA00000000DEMO01-climate', @@ -168,6 +171,7 @@ 'original_name': 'Climate', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate', 'unique_id': 'WBA00000000DEMO03-climate', diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py index 02ec592d061..01b6252229a 100644 --- a/tests/components/bosch_alarm/conftest.py +++ b/tests/components/bosch_alarm/conftest.py @@ -13,7 +13,14 @@ from homeassistant.components.bosch_alarm.const import ( CONF_USER_CODE, DOMAIN, ) -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PASSWORD, CONF_PORT +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_PASSWORD, + CONF_PORT, +) +from homeassistant.helpers.device_registry import format_mac from tests.common import MockConfigEntry @@ -38,6 +45,12 @@ def extra_config_entry_data( return {CONF_MODEL: model_name} | config_flow_data +@pytest.fixture(params=[None]) +def mac_address(request: pytest.FixtureRequest) -> str | None: + """Return entity mac address.""" + return request.param + + @pytest.fixture def config_flow_data(model: str) -> dict[str, Any]: """Return extra config entry data.""" @@ -63,7 +76,7 @@ def model_name(model: str) -> str | None: @pytest.fixture def serial_number(model: str) -> str | None: """Return extra config entry data.""" - if model == "solution_3000": + if model == "b5512": return "1234567890" return None @@ -118,6 +131,8 @@ def door() -> Generator[Door]: mock.name = "Main Door" mock.status_observer = AsyncMock(spec=Observable) mock.is_open.return_value = False + mock.is_cycling.return_value = False + mock.is_secured.return_value = False mock.is_locked.return_value = True return mock @@ -169,6 +184,7 @@ def mock_panel( client.model = model_name client.faults = [] client.events = [] + client.panel_faults_ids = [] client.firmware_version = "1.0.0" client.protocol_version = "1.0.0" client.serial_number = serial_number @@ -180,17 +196,21 @@ def mock_panel( @pytest.fixture def mock_config_entry( - extra_config_entry_data: dict[str, Any], serial_number: str | None + extra_config_entry_data: dict[str, Any], + serial_number: str | None, + mac_address: str | None, ) -> MockConfigEntry: """Mock config entry for bosch alarm.""" + data = { + CONF_HOST: "0.0.0.0", + CONF_PORT: 7700, + CONF_MODEL: "bosch_alarm_test_data.model", + } + if mac_address: + data[CONF_MAC] = format_mac(mac_address) return MockConfigEntry( domain=DOMAIN, unique_id=serial_number, entry_id="01JQ917ACKQ33HHM7YCFXYZX51", - data={ - CONF_HOST: "0.0.0.0", - CONF_PORT: 7700, - CONF_MODEL: "bosch_alarm_test_data.model", - } - | extra_config_entry_data, + data=data | extra_config_entry_data, ) diff --git a/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr index 76568cef56c..ea50a006de0 100644 --- a/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_alarm_control_panel[amax_3000][alarm_control_panel.area1-entry] +# name: test_alarm_control_panel[None-amax_3000][alarm_control_panel.area1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -27,13 +27,14 @@ 'original_name': None, 'platform': 'bosch_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1', 'unit_of_measurement': None, }) # --- -# name: test_alarm_control_panel[amax_3000][alarm_control_panel.area1-state] +# name: test_alarm_control_panel[None-amax_3000][alarm_control_panel.area1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, @@ -50,58 +51,7 @@ 'state': 'disarmed', }) # --- -# name: test_alarm_control_panel[b5512][alarm_control_panel.area1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'alarm_control_panel', - 'entity_category': None, - 'entity_id': 'alarm_control_panel.area1', - 'has_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': 'bosch_alarm', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_alarm_control_panel[b5512][alarm_control_panel.area1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'changed_by': None, - 'code_arm_required': False, - 'code_format': None, - 'friendly_name': 'Area1', - 'supported_features': , - }), - 'context': , - 'entity_id': 'alarm_control_panel.area1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'disarmed', - }) -# --- -# name: test_alarm_control_panel[solution_3000][alarm_control_panel.area1-entry] +# name: test_alarm_control_panel[None-b5512][alarm_control_panel.area1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -129,13 +79,66 @@ 'original_name': None, 'platform': 'bosch_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1234567890_area_1', 'unit_of_measurement': None, }) # --- -# name: test_alarm_control_panel[solution_3000][alarm_control_panel.area1-state] +# name: test_alarm_control_panel[None-b5512][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- +# name: test_alarm_control_panel[None-solution_3000][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[None-solution_3000][alarm_control_panel.area1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, diff --git a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..e3444777ff0 --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr @@ -0,0 +1,3058 @@ +# serializer version: 1 +# name: test_binary_sensor[None-amax_3000][binary_sensor.area1_area_ready_to_arm_away-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.area1_area_ready_to_arm_away', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area ready to arm away', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_away', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_ready_to_arm_away', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.area1_area_ready_to_arm_away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm away', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.area1_area_ready_to_arm_home-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.area1_area_ready_to_arm_home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area ready to arm home', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_home', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_ready_to_arm_home', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.area1_area_ready_to_arm_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm home', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bedroom-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.bedroom', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_ac_failure-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.bosch_amax_3000_ac_failure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_ac_fail', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_ac_fail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_ac_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 AC Failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_ac_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_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.bosch_amax_3000_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_battery_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bosch AMAX 3000 Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_battery_missing-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.bosch_amax_3000_battery_missing', + '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 missing', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_battery_mising', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_battery_mising', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_battery_missing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Battery missing', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_battery_missing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_crc_failure_in_panel_configuration-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.bosch_amax_3000_crc_failure_in_panel_configuration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CRC failure in panel configuration', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_parameter_crc_fail_in_pif', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_parameter_crc_fail_in_pif', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_crc_failure_in_panel_configuration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 CRC failure in panel configuration', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_crc_failure_in_panel_configuration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_failure_to_call_rps_since_last_rps_connection-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.bosch_amax_3000_failure_to_call_rps_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Failure to call RPS since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_failure_to_call_rps_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bosch AMAX 3000 Failure to call RPS since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_failure_to_call_rps_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_log_overflow-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.bosch_amax_3000_log_overflow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log overflow', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_overflow', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_log_overflow', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_log_overflow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Log overflow', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_log_overflow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_log_threshold_reached-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.bosch_amax_3000_log_threshold_reached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log threshold reached', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_threshold', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_log_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_log_threshold_reached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Log threshold reached', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_log_threshold_reached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_phone_line_failure-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.bosch_amax_3000_phone_line_failure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phone line failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_phone_line_failure', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_phone_line_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_phone_line_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bosch AMAX 3000 Phone line failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_phone_line_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_point_bus_failure_since_last_rps_connection-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.bosch_amax_3000_point_bus_failure_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Point bus failure since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_point_bus_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_point_bus_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_point_bus_failure_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Point bus failure since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_point_bus_failure_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_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.bosch_amax_3000_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_communication_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_communication_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_sdi_failure_since_last_rps_connection-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.bosch_amax_3000_sdi_failure_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SDI failure since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_sdi_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_sdi_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_sdi_failure_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 SDI failure since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_sdi_failure_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_user_code_tamper_since_last_rps_connection-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.bosch_amax_3000_user_code_tamper_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User code tamper since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_user_code_tamper_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_user_code_tamper_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_user_code_tamper_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 User code tamper since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_user_code_tamper_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.co_detector-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.co_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.co_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CO Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.co_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.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.door', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door', + }), + 'context': , + 'entity_id': 'binary_sensor.door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.glassbreak_sensor-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.glassbreak_sensor', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.glassbreak_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Glassbreak Sensor', + }), + 'context': , + 'entity_id': 'binary_sensor.glassbreak_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.motion_detector-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.motion_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.motion_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Motion Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.smoke_detector-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.smoke_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.smoke_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.window-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.window', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Window', + }), + 'context': , + 'entity_id': 'binary_sensor.window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.area1_area_ready_to_arm_away-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.area1_area_ready_to_arm_away', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area ready to arm away', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_away', + 'unique_id': '1234567890_area_1_ready_to_arm_away', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.area1_area_ready_to_arm_away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm away', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.area1_area_ready_to_arm_home-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.area1_area_ready_to_arm_home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area ready to arm home', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_home', + 'unique_id': '1234567890_area_1_ready_to_arm_home', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.area1_area_ready_to_arm_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm home', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bedroom-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.bedroom', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_ac_failure-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.bosch_b5512_us1b_ac_failure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_ac_fail', + 'unique_id': '1234567890_fault_panel_fault_ac_fail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_ac_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) AC Failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_ac_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_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.bosch_b5512_us1b_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_fault_panel_fault_battery_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bosch B5512 (US1B) Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_battery_missing-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.bosch_b5512_us1b_battery_missing', + '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 missing', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_battery_mising', + 'unique_id': '1234567890_fault_panel_fault_battery_mising', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_battery_missing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Battery missing', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_battery_missing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_crc_failure_in_panel_configuration-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.bosch_b5512_us1b_crc_failure_in_panel_configuration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CRC failure in panel configuration', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_parameter_crc_fail_in_pif', + 'unique_id': '1234567890_fault_panel_fault_parameter_crc_fail_in_pif', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_crc_failure_in_panel_configuration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) CRC failure in panel configuration', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_crc_failure_in_panel_configuration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_failure_to_call_rps_since_last_rps_connection-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.bosch_b5512_us1b_failure_to_call_rps_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Failure to call RPS since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_failure_to_call_rps_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bosch B5512 (US1B) Failure to call RPS since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_failure_to_call_rps_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_log_overflow-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.bosch_b5512_us1b_log_overflow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log overflow', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_overflow', + 'unique_id': '1234567890_fault_panel_fault_log_overflow', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_log_overflow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Log overflow', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_log_overflow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_log_threshold_reached-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.bosch_b5512_us1b_log_threshold_reached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log threshold reached', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_threshold', + 'unique_id': '1234567890_fault_panel_fault_log_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_log_threshold_reached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Log threshold reached', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_log_threshold_reached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_phone_line_failure-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.bosch_b5512_us1b_phone_line_failure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phone line failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_phone_line_failure', + 'unique_id': '1234567890_fault_panel_fault_phone_line_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_phone_line_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bosch B5512 (US1B) Phone line failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_phone_line_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_point_bus_failure_since_last_rps_connection-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.bosch_b5512_us1b_point_bus_failure_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Point bus failure since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_point_bus_fail_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_point_bus_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_point_bus_failure_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Point bus failure since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_point_bus_failure_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_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.bosch_b5512_us1b_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_communication_fail_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_communication_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_sdi_failure_since_last_rps_connection-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.bosch_b5512_us1b_sdi_failure_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SDI failure since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_sdi_fail_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_sdi_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_sdi_failure_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) SDI failure since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_sdi_failure_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_user_code_tamper_since_last_rps_connection-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.bosch_b5512_us1b_user_code_tamper_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User code tamper since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_user_code_tamper_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_user_code_tamper_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_user_code_tamper_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) User code tamper since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_user_code_tamper_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.co_detector-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.co_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.co_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CO Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.co_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.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.door', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door', + }), + 'context': , + 'entity_id': 'binary_sensor.door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.glassbreak_sensor-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.glassbreak_sensor', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.glassbreak_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Glassbreak Sensor', + }), + 'context': , + 'entity_id': 'binary_sensor.glassbreak_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.motion_detector-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.motion_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.motion_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Motion Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.smoke_detector-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.smoke_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.smoke_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.window-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.window', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Window', + }), + 'context': , + 'entity_id': 'binary_sensor.window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.area1_area_ready_to_arm_away-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.area1_area_ready_to_arm_away', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area ready to arm away', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_away', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_ready_to_arm_away', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.area1_area_ready_to_arm_away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm away', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.area1_area_ready_to_arm_home-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.area1_area_ready_to_arm_home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area ready to arm home', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_home', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_ready_to_arm_home', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.area1_area_ready_to_arm_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm home', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bedroom-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.bedroom', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_ac_failure-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.bosch_solution_3000_ac_failure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_ac_fail', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_ac_fail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_ac_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 AC Failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_ac_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_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.bosch_solution_3000_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_battery_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bosch Solution 3000 Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_battery_missing-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.bosch_solution_3000_battery_missing', + '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 missing', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_battery_mising', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_battery_mising', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_battery_missing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Battery missing', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_battery_missing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_crc_failure_in_panel_configuration-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.bosch_solution_3000_crc_failure_in_panel_configuration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CRC failure in panel configuration', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_parameter_crc_fail_in_pif', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_parameter_crc_fail_in_pif', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_crc_failure_in_panel_configuration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 CRC failure in panel configuration', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_crc_failure_in_panel_configuration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_failure_to_call_rps_since_last_rps_connection-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.bosch_solution_3000_failure_to_call_rps_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Failure to call RPS since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_failure_to_call_rps_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bosch Solution 3000 Failure to call RPS since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_failure_to_call_rps_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_log_overflow-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.bosch_solution_3000_log_overflow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log overflow', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_overflow', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_log_overflow', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_log_overflow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Log overflow', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_log_overflow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_log_threshold_reached-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.bosch_solution_3000_log_threshold_reached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log threshold reached', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_threshold', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_log_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_log_threshold_reached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Log threshold reached', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_log_threshold_reached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_phone_line_failure-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.bosch_solution_3000_phone_line_failure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phone line failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_phone_line_failure', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_phone_line_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_phone_line_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bosch Solution 3000 Phone line failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_phone_line_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_point_bus_failure_since_last_rps_connection-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.bosch_solution_3000_point_bus_failure_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Point bus failure since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_point_bus_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_point_bus_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_point_bus_failure_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Point bus failure since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_point_bus_failure_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_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.bosch_solution_3000_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_communication_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_communication_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_sdi_failure_since_last_rps_connection-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.bosch_solution_3000_sdi_failure_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SDI failure since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_sdi_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_sdi_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_sdi_failure_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 SDI failure since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_sdi_failure_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_user_code_tamper_since_last_rps_connection-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.bosch_solution_3000_user_code_tamper_since_last_rps_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User code tamper since last RPS connection', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_user_code_tamper_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_user_code_tamper_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_user_code_tamper_since_last_rps_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 User code tamper since last RPS connection', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_user_code_tamper_since_last_rps_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.co_detector-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.co_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.co_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CO Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.co_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.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.door', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door', + }), + 'context': , + 'entity_id': 'binary_sensor.door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.glassbreak_sensor-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.glassbreak_sensor', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.glassbreak_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Glassbreak Sensor', + }), + 'context': , + 'entity_id': 'binary_sensor.glassbreak_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.motion_detector-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.motion_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.motion_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Motion Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.smoke_detector-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.smoke_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.smoke_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.window-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.window', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Window', + }), + 'context': , + 'entity_id': 'binary_sensor.window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr index 459ddf7a213..ad8b7cfbc38 100644 --- a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr +++ b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_diagnostics[amax_3000] +# name: test_diagnostics[amax_3000-None] dict({ 'data': dict({ 'areas': list([ @@ -95,7 +95,7 @@ }), }) # --- -# name: test_diagnostics[b5512] +# name: test_diagnostics[b5512-None] dict({ 'data': dict({ 'areas': list([ @@ -180,103 +180,103 @@ }), ]), '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': 'B5512 (US1B)', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[solution_3000-None] + 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': None, + }), 'entry_data': dict({ 'host': '0.0.0.0', 'model': 'Solution 3000', diff --git a/tests/components/bosch_alarm/snapshots/test_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_sensor.ambr index def2c503a6a..dc229c15918 100644 --- a/tests/components/bosch_alarm/snapshots/test_sensor.ambr +++ b/tests/components/bosch_alarm/snapshots/test_sensor.ambr @@ -1,5 +1,53 @@ # serializer version: 1 -# name: test_sensor[amax_3000][sensor.area1_faulting_points-entry] +# name: test_sensor[None-amax_3000][sensor.area1_burglary_alarm_issues-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_burglary_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Burglary alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_burglary', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_burglary', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_burglary_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Burglary alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_burglary_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_faulting_points-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -27,13 +75,14 @@ 'original_name': 'Faulting points', 'platform': 'bosch_alarm', 'previous_unique_id': None, + 'suggested_object_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] +# name: test_sensor[None-amax_3000][sensor.area1_faulting_points-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Area1 Faulting points', @@ -47,7 +96,7 @@ 'state': '0', }) # --- -# name: test_sensor[b5512][sensor.area1_faulting_points-entry] +# name: test_sensor[None-amax_3000][sensor.area1_fire_alarm_issues-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,7 +109,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.area1_faulting_points', + 'entity_id': 'sensor.area1_fire_alarm_issues', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -72,30 +121,126 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Faulting points', + 'original_name': 'Fire alarm issues', 'platform': 'bosch_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'faulting_points', - 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', - 'unit_of_measurement': 'points', + 'translation_key': 'alarms_fire', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_fire', + 'unit_of_measurement': None, }) # --- -# name: test_sensor[b5512][sensor.area1_faulting_points-state] +# name: test_sensor[None-amax_3000][sensor.area1_fire_alarm_issues-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Area1 Faulting points', - 'unit_of_measurement': 'points', + 'friendly_name': 'Area1 Fire alarm issues', }), 'context': , - 'entity_id': 'sensor.area1_faulting_points', + 'entity_id': 'sensor.area1_fire_alarm_issues', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'no_issues', }) # --- -# name: test_sensor[solution_3000][sensor.area1_faulting_points-entry] +# name: test_sensor[None-amax_3000][sensor.area1_gas_alarm_issues-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_gas_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_gas', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_gas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_gas_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Gas alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_gas_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_burglary_alarm_issues-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_burglary_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Burglary alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_burglary', + 'unique_id': '1234567890_area_1_alarms_burglary', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_burglary_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Burglary alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_burglary_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_faulting_points-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -123,13 +268,14 @@ 'original_name': 'Faulting points', 'platform': 'bosch_alarm', 'previous_unique_id': None, + 'suggested_object_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] +# name: test_sensor[None-b5512][sensor.area1_faulting_points-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Area1 Faulting points', @@ -143,3 +289,292 @@ 'state': '0', }) # --- +# name: test_sensor[None-b5512][sensor.area1_fire_alarm_issues-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_fire_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fire alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_fire', + 'unique_id': '1234567890_area_1_alarms_fire', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_fire_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Fire alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_fire_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_gas_alarm_issues-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_gas_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_gas', + 'unique_id': '1234567890_area_1_alarms_gas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_gas_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Gas alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_gas_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_burglary_alarm_issues-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_burglary_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Burglary alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_burglary', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_burglary', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_burglary_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Burglary alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_burglary_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[None-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', + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_fire_alarm_issues-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_fire_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fire alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_fire', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_fire', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_fire_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Fire alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_fire_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_gas_alarm_issues-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_gas_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_gas', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_gas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_gas_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Gas alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_gas_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- diff --git a/tests/components/bosch_alarm/snapshots/test_switch.ambr b/tests/components/bosch_alarm/snapshots/test_switch.ambr new file mode 100644 index 00000000000..f9e4d063e50 --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_switch.ambr @@ -0,0 +1,577 @@ +# serializer version: 1 +# name: test_switch[None-amax_3000][switch.main_door_locked-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.main_door_locked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Locked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'locked', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_locked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Locked', + }), + 'context': , + 'entity_id': 'switch.main_door_locked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_momentarily_unlocked-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.main_door_momentarily_unlocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Momentarily unlocked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cycling', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_cycling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_momentarily_unlocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Momentarily unlocked', + }), + 'context': , + 'entity_id': 'switch.main_door_momentarily_unlocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_secured-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.main_door_secured', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Secured', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'secured', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_secured', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_secured-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Secured', + }), + 'context': , + 'entity_id': 'switch.main_door_secured', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-amax_3000][switch.output_a-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.output_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_output_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-amax_3000][switch.output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Output A', + }), + 'context': , + 'entity_id': 'switch.output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-b5512][switch.main_door_locked-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.main_door_locked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Locked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'locked', + 'unique_id': '1234567890_door_1_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-b5512][switch.main_door_locked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Locked', + }), + 'context': , + 'entity_id': 'switch.main_door_locked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[None-b5512][switch.main_door_momentarily_unlocked-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.main_door_momentarily_unlocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Momentarily unlocked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cycling', + 'unique_id': '1234567890_door_1_cycling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-b5512][switch.main_door_momentarily_unlocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Momentarily unlocked', + }), + 'context': , + 'entity_id': 'switch.main_door_momentarily_unlocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-b5512][switch.main_door_secured-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.main_door_secured', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Secured', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'secured', + 'unique_id': '1234567890_door_1_secured', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-b5512][switch.main_door_secured-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Secured', + }), + 'context': , + 'entity_id': 'switch.main_door_secured', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-b5512][switch.output_a-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.output_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_output_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-b5512][switch.output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Output A', + }), + 'context': , + 'entity_id': 'switch.output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_locked-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.main_door_locked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Locked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'locked', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_locked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Locked', + }), + 'context': , + 'entity_id': 'switch.main_door_locked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_momentarily_unlocked-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.main_door_momentarily_unlocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Momentarily unlocked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cycling', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_cycling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_momentarily_unlocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Momentarily unlocked', + }), + 'context': , + 'entity_id': 'switch.main_door_momentarily_unlocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_secured-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.main_door_secured', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Secured', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'secured', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_secured', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_secured-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Secured', + }), + 'context': , + 'entity_id': 'switch.main_door_secured', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-solution_3000][switch.output_a-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.output_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_output_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-solution_3000][switch.output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Output A', + }), + 'context': , + 'entity_id': 'switch.output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/bosch_alarm/test_alarm_control_panel.py b/tests/components/bosch_alarm/test_alarm_control_panel.py index 31d2f928ec5..51767396880 100644 --- a/tests/components/bosch_alarm/test_alarm_control_panel.py +++ b/tests/components/bosch_alarm/test_alarm_control_panel.py @@ -66,6 +66,16 @@ async def test_update_alarm_device( assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_AWAY + area.is_triggered.return_value = True + + await call_observable(hass, area.alarm_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.TRIGGERED + + area.is_triggered.return_value = False + + await call_observable(hass, area.alarm_observer) + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, diff --git a/tests/components/bosch_alarm/test_binary_sensor.py b/tests/components/bosch_alarm/test_binary_sensor.py new file mode 100644 index 00000000000..e788d7c5eda --- /dev/null +++ b/tests/components/bosch_alarm/test_binary_sensor.py @@ -0,0 +1,78 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, 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.BINARY_SENSOR] + ): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the binary sensor state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_panel_faults( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that fault sensor state changes after inducing a fault.""" + await setup_integration(hass, mock_config_entry) + entity_id = "binary_sensor.bosch_b5512_us1b_battery" + assert hass.states.get(entity_id).state == STATE_OFF + mock_panel.panel_faults_ids = [ALARM_PANEL_FAULTS.BATTERY_LOW] + await call_observable(hass, mock_panel.faults_observer) + assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_area_ready_to_arm( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that fault sensor state changes after inducing a fault.""" + await setup_integration(hass, mock_config_entry) + entity_id = "binary_sensor.area1_area_ready_to_arm_away" + entity_id_2 = "binary_sensor.area1_area_ready_to_arm_home" + assert hass.states.get(entity_id).state == STATE_ON + assert hass.states.get(entity_id_2).state == STATE_ON + area.all_ready = False + await call_observable(hass, area.status_observer) + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id_2).state == STATE_ON + area.part_ready = False + await call_observable(hass, area.status_observer) + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id_2).state == STATE_OFF diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py index 9e79d1c1f5f..d39bff935d5 100644 --- a/tests/components/bosch_alarm/test_config_flow.py +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock import pytest -from homeassistant import config_entries from homeassistant.components.bosch_alarm.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PORT +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import setup_integration @@ -77,7 +77,7 @@ async def test_form_exceptions( """Test we handle exceptions correctly.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -174,13 +174,6 @@ async def test_entry_already_configured_host( result["flow_id"], {CONF_HOST: "0.0.0.0"} ) - 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"] == "already_configured" @@ -200,7 +193,7 @@ async def test_entry_already_configured_serial( ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "0.0.0.0"} + result["flow_id"], {CONF_HOST: "1.1.1.1"} ) assert result["type"] is FlowResultType.FORM @@ -214,6 +207,218 @@ async def test_entry_already_configured_serial( assert result["reason"] == "already_configured" +async def test_dhcp_can_finish( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test DHCP discovery flow can finish right away.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="1.1.1.1", + macaddress="34ea34b43b5a", + ), + ) + 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, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Bosch {model_name}" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_MAC: "34:ea:34:b4:3b:5a", + CONF_PORT: 7700, + CONF_MODEL: model_name, + **config_flow_data, + } + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (asyncio.exceptions.TimeoutError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_dhcp_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test DHCP discovery flow that fails to connect.""" + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="1.1.1.1", + macaddress="34ea34b43b5a", + ), + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == message + + +@pytest.mark.parametrize("mac_address", ["34ea34b43b5a"]) +async def test_dhcp_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + mac_address: str | None, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test DHCP updates host.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="4.5.6.7", + macaddress=mac_address, + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "4.5.6.7" + + +@pytest.mark.parametrize("serial_number", ["12345678"]) +async def test_dhcp_discovery_if_panel_setup_config_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + serial_number: str, + model_name: str, + config_flow_data: dict[str, Any], +) -> None: + """Test DHCP discovery doesn't fail if a different panel was set up via config flow.""" + await setup_integration(hass, mock_config_entry) + + # change out the serial number so we can test discovery for a different panel + mock_panel.serial_number = "789101112" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="4.5.6.7", + macaddress="34ea34b43b5a", + ), + ) + await hass.async_block_till_done() + + 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, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Bosch {model_name}" + assert result["data"] == { + CONF_HOST: "4.5.6.7", + CONF_MAC: "34:ea:34:b4:3b:5a", + CONF_PORT: 7700, + CONF_MODEL: model_name, + **config_flow_data, + } + assert mock_config_entry.unique_id == serial_number + assert result["result"].unique_id == "789101112" + + +@pytest.mark.parametrize("model", ["solution_3000", "amax_3000"]) +async def test_dhcp_abort_ongoing_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], +) -> None: + """Test if a dhcp flow is aborted if there is already an ongoing flow.""" + + 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: "0.0.0.0"} + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="0.0.0.0", + macaddress="34ea34b43b5a", + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_dhcp_updates_mac( + 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 DHCP discovery flow updates mac if the previous entry did not have a mac address.""" + await setup_integration(hass, mock_config_entry) + assert CONF_MAC not in mock_config_entry.data + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="0.0.0.0", + macaddress="34ea34b43b5a", + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_MAC] == "34:ea:34:b4:3b:5a" + + async def test_reauth_flow_success( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -274,7 +479,6 @@ async def test_reauth_flow_error( ) 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"], @@ -301,7 +505,7 @@ async def test_reconfig_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={ - "source": config_entries.SOURCE_RECONFIGURE, + "source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id, }, ) @@ -347,7 +551,7 @@ async def test_reconfig_flow_incorrect_model( result = await hass.config_entries.flow.async_init( DOMAIN, context={ - "source": config_entries.SOURCE_RECONFIGURE, + "source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id, }, ) diff --git a/tests/components/bosch_alarm/test_sensor.py b/tests/components/bosch_alarm/test_sensor.py index 02153a9656e..c986fdab733 100644 --- a/tests/components/bosch_alarm/test_sensor.py +++ b/tests/components/bosch_alarm/test_sensor.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, patch +from bosch_alarm_mode2.const import ALARM_MEMORY_PRIORITIES import pytest from syrupy.assertion import SnapshotAssertion @@ -48,5 +49,21 @@ async def test_faulting_points( area.faults = 1 await call_observable(hass, area.ready_observer) - assert hass.states.get(entity_id).state == "1" + + +async def test_alarm_faults( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that alarm state changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.area1_fire_alarm_issues" + assert hass.states.get(entity_id).state == "no_issues" + + area.alarms_ids = [ALARM_MEMORY_PRIORITIES.FIRE_TROUBLE] + await call_observable(hass, area.alarm_observer) + + assert hass.states.get(entity_id).state == "trouble" diff --git a/tests/components/bosch_alarm/test_services.py b/tests/components/bosch_alarm/test_services.py new file mode 100644 index 00000000000..7b5088f32c3 --- /dev/null +++ b/tests/components/bosch_alarm/test_services.py @@ -0,0 +1,192 @@ +"""Tests for Bosch Alarm component.""" + +import asyncio +from collections.abc import AsyncGenerator +import datetime as dt +from unittest.mock import AsyncMock, patch + +import pytest +import voluptuous as vol + +from homeassistant.components.bosch_alarm.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_DATETIME, + DOMAIN, + SERVICE_SET_DATE_TIME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@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", []): + yield + + +async def test_set_date_time_service( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls succeed if the service call is valid.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt_util.now(), + }, + blocking=True, + ) + mock_panel.set_panel_date.assert_called_once() + + +async def test_set_date_time_service_fails_bad_entity( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the service call is done for an incorrect entity.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises( + ServiceValidationError, + match='Integration "bad-config_id" not found in registry', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: "bad-config_id", + ATTR_DATETIME: dt_util.now(), + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_bad_params( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the service call is done with incorrect params.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises( + vol.MultipleInvalid, + match=r"Invalid datetime specified: for dictionary value @ data\['datetime'\]", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: "", + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_bad_year_before( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the panel fails the service call.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises( + vol.MultipleInvalid, + match=r"datetime must be before 2038 for dictionary value @ data\['datetime'\]", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt.datetime(2038, 1, 1), + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_bad_year_after( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the panel fails the service call.""" + await setup_integration(hass, mock_config_entry) + mock_panel.set_panel_date.side_effect = ValueError() + with pytest.raises( + vol.MultipleInvalid, + match=r"datetime must be after 2009 for dictionary value @ data\['datetime'\]", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt.datetime(2009, 1, 1), + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_connection_error( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the panel fails the service call.""" + await setup_integration(hass, mock_config_entry) + mock_panel.set_panel_date.side_effect = asyncio.InvalidStateError() + with pytest.raises( + HomeAssistantError, + match=f'Could not connect to "{mock_config_entry.title}"', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt_util.now(), + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_unloaded( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the config entry is unloaded.""" + await async_setup_component(hass, DOMAIN, {}) + mock_config_entry.add_to_hass(hass) + with pytest.raises( + HomeAssistantError, + match=f"{mock_config_entry.title} is not loaded", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt_util.now(), + }, + blocking=True, + ) diff --git a/tests/components/bosch_alarm/test_switch.py b/tests/components/bosch_alarm/test_switch.py new file mode 100644 index 00000000000..2c52c21099a --- /dev/null +++ b/tests/components/bosch_alarm/test_switch.py @@ -0,0 +1,147 @@ +"""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.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 . 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.SWITCH]): + yield + + +async def test_update_switch_device( + hass: HomeAssistant, + mock_panel: AsyncMock, + output: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that output state changes after turning on the output.""" + await setup_integration(hass, mock_config_entry) + entity_id = "switch.output_a" + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + output.is_active.return_value = True + await call_observable(hass, output.status_observer) + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_unlock_door( + hass: HomeAssistant, + mock_panel: AsyncMock, + door: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that door state changes after unlocking the door.""" + await setup_integration(hass, mock_config_entry) + entity_id = "switch.main_door_locked" + assert hass.states.get(entity_id).state == STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_locked.return_value = False + door.is_open.return_value = True + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_locked.return_value = True + door.is_open.return_value = False + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_secure_door( + hass: HomeAssistant, + mock_panel: AsyncMock, + door: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that door state changes after unlocking the door.""" + await setup_integration(hass, mock_config_entry) + entity_id = "switch.main_door_secured" + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_secured.return_value = True + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_secured.return_value = False + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_cycle_door( + hass: HomeAssistant, + mock_panel: AsyncMock, + door: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that door state changes after unlocking the door.""" + await setup_integration(hass, mock_config_entry) + entity_id = "switch.main_door_momentarily_unlocked" + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_cycling.return_value = True + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_switch( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/braviatv/test_diagnostics.py b/tests/components/braviatv/test_diagnostics.py index a7bd1631788..2f6df722909 100644 --- a/tests/components/braviatv/test_diagnostics.py +++ b/tests/components/braviatv/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.braviatv.const import CONF_USE_PSK, DOMAIN diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 3f4c8f5f339..4c8475428e9 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'data': dict({ + 'activity': dict({ 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ 'activity': dict({ 'timeline': list([ @@ -79,58 +79,6 @@ 'timestamp': '2025-01-01T03:09:33.036000+00:00', 'totalEvents': 3, }), - 'content': dict({ - 'items': dict({ - 'purchase': list([ - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', - }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - }), - 'status': 'REGISTERED', - 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - }), - 'lst': dict({ - 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': '**REDACTED**', - 'theme': 'ch.publisheria.bring.theme.home', - }), 'users': dict({ 'users': list([ dict({ @@ -246,6 +194,101 @@ 'timestamp': '2025-01-01T03:09:33.036000+00:00', 'totalEvents': 3, }), + 'users': dict({ + 'users': list([ + dict({ + 'country': 'DE', + 'email': '**REDACTED**', + 'language': 'de', + 'name': '**REDACTED**', + 'photoPath': '', + 'plusExpiry': None, + 'plusTryOut': False, + 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': '**REDACTED**', + 'language': 'en', + 'name': '**REDACTED**', + 'photoPath': '', + 'plusExpiry': None, + 'plusTryOut': False, + 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': None, + 'language': 'en', + 'name': None, + 'photoPath': None, + 'plusExpiry': None, + 'plusTryOut': False, + 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'pushEnabled': True, + }), + ]), + }), + }), + }), + 'data': dict({ + 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ + 'content': dict({ + 'items': dict({ + 'purchase': list([ + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + }), + 'status': 'REGISTERED', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + }), + 'lst': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), + }), + 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ 'content': dict({ 'items': dict({ 'purchase': list([ @@ -295,46 +338,9 @@ }), 'lst': dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - 'name': '**REDACTED**', + 'name': 'Einkauf', 'theme': 'ch.publisheria.bring.theme.home', }), - 'users': dict({ - 'users': list([ - dict({ - 'country': 'DE', - 'email': '**REDACTED**', - 'language': 'de', - 'name': '**REDACTED**', - 'photoPath': '', - 'plusExpiry': None, - 'plusTryOut': False, - 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', - 'pushEnabled': True, - }), - dict({ - 'country': 'US', - 'email': '**REDACTED**', - 'language': 'en', - 'name': '**REDACTED**', - 'photoPath': '', - 'plusExpiry': None, - 'plusTryOut': False, - 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', - 'pushEnabled': True, - }), - dict({ - 'country': 'US', - 'email': None, - 'language': 'en', - 'name': None, - 'photoPath': None, - 'plusExpiry': None, - 'plusTryOut': False, - 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', - 'pushEnabled': True, - }), - ]), - }), }), }), 'lists': list([ diff --git a/tests/components/bring/snapshots/test_event.ambr b/tests/components/bring/snapshots/test_event.ambr index 0bcdcb5b565..ceaef2bef87 100644 --- a/tests/components/bring/snapshots/test_event.ambr +++ b/tests/components/bring/snapshots/test_event.ambr @@ -33,6 +33,7 @@ 'original_name': 'Activities', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activities', 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_activities', @@ -117,6 +118,7 @@ 'original_name': 'Activities', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activities', 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_activities', diff --git a/tests/components/bring/snapshots/test_sensor.ambr b/tests/components/bring/snapshots/test_sensor.ambr index eb307d31396..f3b37fd8b21 100644 --- a/tests/components/bring/snapshots/test_sensor.ambr +++ b/tests/components/bring/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Discount only', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_discounted', @@ -81,6 +82,7 @@ 'original_name': 'List access', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_list_access', @@ -134,6 +136,7 @@ 'original_name': 'On occasion', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_convenient', @@ -205,6 +208,7 @@ 'original_name': 'Region & language', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_list_language', @@ -275,6 +279,7 @@ 'original_name': 'Urgent', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_urgent', @@ -323,6 +328,7 @@ 'original_name': 'Discount only', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_discounted', @@ -377,6 +383,7 @@ 'original_name': 'List access', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_list_access', @@ -430,6 +437,7 @@ 'original_name': 'On occasion', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_convenient', @@ -501,6 +509,7 @@ 'original_name': 'Region & language', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_list_language', @@ -571,6 +580,7 @@ 'original_name': 'Urgent', 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_urgent', diff --git a/tests/components/bring/snapshots/test_todo.ambr b/tests/components/bring/snapshots/test_todo.ambr index 46146415bf6..bc65c6b020b 100644 --- a/tests/components/bring/snapshots/test_todo.ambr +++ b/tests/components/bring/snapshots/test_todo.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'shopping_list', 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd', @@ -75,6 +76,7 @@ 'original_name': None, 'platform': 'bring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'shopping_list', 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5', diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index f053f294ef1..7f235ea505c 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -139,6 +139,31 @@ async def test_config_entry_not_ready_udpdate_failed( assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize( + ("exception", "state"), + [ + (BringRequestException, ConfigEntryState.SETUP_RETRY), + (BringParseException, ConfigEntryState.SETUP_RETRY), + (BringAuthException, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_activity_coordinator_errors( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_bring_client.get_activity.side_effect = exception + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is state + + @pytest.mark.parametrize( ("exception", "state"), [ @@ -263,3 +288,44 @@ async def test_create_devices( assert device_registry.async_get_device( {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} ) + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_coordinator_update_intervals( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_bring_client: AsyncMock, +) -> None: + """Test the coordinator updates at the specified intervals.""" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + # fetch 2 lists on first refresh + assert mock_bring_client.load_lists.await_count == 2 + assert mock_bring_client.get_activity.await_count == 2 + + mock_bring_client.load_lists.reset_mock() + mock_bring_client.get_activity.reset_mock() + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # main coordinator refreshes, activity does not + assert mock_bring_client.load_lists.await_count == 1 + assert mock_bring_client.get_activity.await_count == 0 + + mock_bring_client.load_lists.reset_mock() + mock_bring_client.get_activity.reset_mock() + + freezer.tick(timedelta(seconds=510)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # assert activity refreshes after 10min and has up-to-date lists data + assert mock_bring_client.get_activity.await_count == 1 diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 673c4e68a4d..a1d7de2b553 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -1,12 +1,6 @@ """Test for utility functions of the Bring! integration.""" -from bring_api import ( - BringActivityResponse, - BringItemsResponse, - BringListResponse, - BringUserSettingsResponse, -) -from bring_api.types import BringUsersResponse +from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse import pytest from homeassistant.components.bring.const import DOMAIN @@ -47,10 +41,8 @@ def test_sum_attributes(attribute: str, expected: int) -> None: """Test function sum_attributes.""" items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN)) - activity = BringActivityResponse.from_json(load_fixture("activity.json", DOMAIN)) - users = BringUsersResponse.from_json(load_fixture("users.json", DOMAIN)) result = sum_attributes( - BringData(lst.lists[0], items, activity, users), + BringData(lst.lists[0], items), attribute, ) diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr index 847ea0a2c6b..b25d6a20a65 100644 --- a/tests/components/brother/snapshots/test_sensor.ambr +++ b/tests/components/brother/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'B/W pages', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bw_pages', 'unique_id': '0123456789_bw_counter', @@ -80,6 +81,7 @@ 'original_name': 'Belt unit remaining lifetime', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'belt_unit_remaining_life', 'unique_id': '0123456789_belt_unit_remaining_life', @@ -131,6 +133,7 @@ 'original_name': 'Black drum page counter', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'black_drum_page_counter', 'unique_id': '0123456789_black_drum_counter', @@ -182,6 +185,7 @@ 'original_name': 'Black drum remaining lifetime', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'black_drum_remaining_life', 'unique_id': '0123456789_black_drum_remaining_life', @@ -233,6 +237,7 @@ 'original_name': 'Black drum remaining pages', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'black_drum_remaining_pages', 'unique_id': '0123456789_black_drum_remaining_pages', @@ -284,6 +289,7 @@ 'original_name': 'Black toner remaining', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'black_toner_remaining', 'unique_id': '0123456789_black_toner_remaining', @@ -335,6 +341,7 @@ 'original_name': 'Color pages', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'color_pages', 'unique_id': '0123456789_color_counter', @@ -386,6 +393,7 @@ 'original_name': 'Cyan drum page counter', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cyan_drum_page_counter', 'unique_id': '0123456789_cyan_drum_counter', @@ -437,6 +445,7 @@ 'original_name': 'Cyan drum remaining lifetime', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cyan_drum_remaining_life', 'unique_id': '0123456789_cyan_drum_remaining_life', @@ -488,6 +497,7 @@ 'original_name': 'Cyan drum remaining pages', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cyan_drum_remaining_pages', 'unique_id': '0123456789_cyan_drum_remaining_pages', @@ -539,6 +549,7 @@ 'original_name': 'Cyan toner remaining', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cyan_toner_remaining', 'unique_id': '0123456789_cyan_toner_remaining', @@ -590,6 +601,7 @@ 'original_name': 'Drum page counter', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drum_page_counter', 'unique_id': '0123456789_drum_counter', @@ -641,6 +653,7 @@ 'original_name': 'Drum remaining lifetime', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drum_remaining_life', 'unique_id': '0123456789_drum_remaining_life', @@ -692,6 +705,7 @@ 'original_name': 'Drum remaining pages', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drum_remaining_pages', 'unique_id': '0123456789_drum_remaining_pages', @@ -743,6 +757,7 @@ 'original_name': 'Duplex unit page counter', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'duplex_unit_page_counter', 'unique_id': '0123456789_duplex_unit_pages_counter', @@ -794,6 +809,7 @@ 'original_name': 'Fuser remaining lifetime', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fuser_remaining_life', 'unique_id': '0123456789_fuser_remaining_life', @@ -843,6 +859,7 @@ 'original_name': 'Last restart', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_restart', 'unique_id': '0123456789_uptime', @@ -893,6 +910,7 @@ 'original_name': 'Magenta drum page counter', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'magenta_drum_page_counter', 'unique_id': '0123456789_magenta_drum_counter', @@ -944,6 +962,7 @@ 'original_name': 'Magenta drum remaining lifetime', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'magenta_drum_remaining_life', 'unique_id': '0123456789_magenta_drum_remaining_life', @@ -995,6 +1014,7 @@ 'original_name': 'Magenta drum remaining pages', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'magenta_drum_remaining_pages', 'unique_id': '0123456789_magenta_drum_remaining_pages', @@ -1046,6 +1066,7 @@ 'original_name': 'Magenta toner remaining', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'magenta_toner_remaining', 'unique_id': '0123456789_magenta_toner_remaining', @@ -1097,6 +1118,7 @@ 'original_name': 'Page counter', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'page_counter', 'unique_id': '0123456789_page_counter', @@ -1148,6 +1170,7 @@ 'original_name': 'PF Kit 1 remaining lifetime', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pf_kit_1_remaining_life', 'unique_id': '0123456789_pf_kit_1_remaining_life', @@ -1197,6 +1220,7 @@ 'original_name': 'Status', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', 'unique_id': '0123456789_status', @@ -1246,6 +1270,7 @@ 'original_name': 'Yellow drum page counter', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yellow_drum_page_counter', 'unique_id': '0123456789_yellow_drum_counter', @@ -1297,6 +1322,7 @@ 'original_name': 'Yellow drum remaining lifetime', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yellow_drum_remaining_life', 'unique_id': '0123456789_yellow_drum_remaining_life', @@ -1348,6 +1374,7 @@ 'original_name': 'Yellow drum remaining pages', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yellow_drum_remaining_pages', 'unique_id': '0123456789_yellow_drum_remaining_pages', @@ -1399,6 +1426,7 @@ 'original_name': 'Yellow toner remaining', 'platform': 'brother', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yellow_toner_remaining', 'unique_id': '0123456789_yellow_toner_remaining', diff --git a/tests/components/brother/test_diagnostics.py b/tests/components/brother/test_diagnostics.py index 117990b6470..493f2993555 100644 --- a/tests/components/brother/test_diagnostics.py +++ b/tests/components/brother/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 8069b27e307..28d08cd6b2f 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.brother.const import DOMAIN, UPDATE_INTERVAL from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN diff --git a/tests/components/bryant_evolution/snapshots/test_climate.ambr b/tests/components/bryant_evolution/snapshots/test_climate.ambr index 3aeaf66329f..4b38e532139 100644 --- a/tests/components/bryant_evolution/snapshots/test_climate.ambr +++ b/tests/components/bryant_evolution/snapshots/test_climate.ambr @@ -42,6 +42,7 @@ 'original_name': None, 'platform': 'bryant_evolution', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01J3XJZSTEF6G5V0QJX6HBC94T-S1-Z1', diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr index 70d13f1cb95..9efd1b79e29 100644 --- a/tests/components/bsblan/snapshots/test_climate.ambr +++ b/tests/components/bsblan/snapshots/test_climate.ambr @@ -39,6 +39,7 @@ 'original_name': None, 'platform': 'bsblan', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:80:41:19:69:90-climate', @@ -113,6 +114,7 @@ 'original_name': None, 'platform': 'bsblan', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:80:41:19:69:90-climate', diff --git a/tests/components/bsblan/snapshots/test_sensor.ambr b/tests/components/bsblan/snapshots/test_sensor.ambr index df7ceecc957..eb80858eb5d 100644 --- a/tests/components/bsblan/snapshots/test_sensor.ambr +++ b/tests/components/bsblan/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current Temperature', 'platform': 'bsblan', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_temperature', 'unique_id': '00:80:41:19:69:90-current_temperature', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside Temperature', 'platform': 'bsblan', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': '00:80:41:19:69:90-outside_temperature', diff --git a/tests/components/bsblan/snapshots/test_water_heater.ambr b/tests/components/bsblan/snapshots/test_water_heater.ambr index 37fdb14aca9..4ff20fd06d4 100644 --- a/tests/components/bsblan/snapshots/test_water_heater.ambr +++ b/tests/components/bsblan/snapshots/test_water_heater.ambr @@ -35,6 +35,7 @@ 'original_name': None, 'platform': 'bsblan', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:80:41:19:69:90', diff --git a/tests/components/bsblan/test_diagnostics.py b/tests/components/bsblan/test_diagnostics.py index aea53f8a1a2..c6b6c92e718 100644 --- a/tests/components/bsblan/test_diagnostics.py +++ b/tests/components/bsblan/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/cambridge_audio/snapshots/test_select.ambr b/tests/components/cambridge_audio/snapshots/test_select.ambr index 8c9801b101b..8e95966bc6a 100644 --- a/tests/components/cambridge_audio/snapshots/test_select.ambr +++ b/tests/components/cambridge_audio/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Audio output', 'platform': 'cambridge_audio', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'audio_output', 'unique_id': '0020c2d8-audio_output', @@ -57,6 +58,65 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[select.cambridge_audio_cxnv2_control_bus_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'amplifier', + 'receiver', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cambridge_audio_cxnv2_control_bus_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': 'Control Bus mode', + 'platform': 'cambridge_audio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'control_bus_mode', + 'unique_id': '0020c2d8-control_bus_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.cambridge_audio_cxnv2_control_bus_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cambridge Audio CXNv2 Control Bus mode', + 'options': list([ + 'amplifier', + 'receiver', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.cambridge_audio_cxnv2_control_bus_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[select.cambridge_audio_cxnv2_display_brightness-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -91,6 +151,7 @@ 'original_name': 'Display brightness', 'platform': 'cambridge_audio', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display_brightness', 'unique_id': '0020c2d8-display_brightness', diff --git a/tests/components/cambridge_audio/snapshots/test_switch.ambr b/tests/components/cambridge_audio/snapshots/test_switch.ambr index cd4326fdcc3..63ac2b8a00c 100644 --- a/tests/components/cambridge_audio/snapshots/test_switch.ambr +++ b/tests/components/cambridge_audio/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Early update', 'platform': 'cambridge_audio', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'early_update', 'unique_id': '0020c2d8-early_update', @@ -74,6 +75,7 @@ 'original_name': 'Pre-Amp', 'platform': 'cambridge_audio', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pre_amp', 'unique_id': '0020c2d8-pre_amp', diff --git a/tests/components/cambridge_audio/test_diagnostics.py b/tests/components/cambridge_audio/test_diagnostics.py index 9c1a09c6318..42367a67876 100644 --- a/tests/components/cambridge_audio/test_diagnostics.py +++ b/tests/components/cambridge_audio/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/cambridge_audio/test_init.py b/tests/components/cambridge_audio/test_init.py index a058f7c8b6c..507a942c30f 100644 --- a/tests/components/cambridge_audio/test_init.py +++ b/tests/components/cambridge_audio/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock from aiostreammagic import StreamMagicError from aiostreammagic.models import CallbackType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cambridge_audio.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/cambridge_audio/test_media_browser.py b/tests/components/cambridge_audio/test_media_browser.py index da72cfab534..1e374566611 100644 --- a/tests/components/cambridge_audio/test_media_browser.py +++ b/tests/components/cambridge_audio/test_media_browser.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index ef7e911fbba..10e9311c4b0 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from aiostreammagic import ( + ControlBusMode, RepeatMode as CambridgeRepeatMode, ShuffleMode, TransportControl, @@ -129,6 +130,29 @@ async def test_entity_supported_features( ) +async def test_entity_supported_features_with_control_bus( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entity attributes with control bus state.""" + await setup_integration(hass, mock_config_entry) + + mock_stream_magic_client.state.pre_amp_mode = False + mock_stream_magic_client.state.control_bus = ControlBusMode.AMPLIFIER + + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + attrs = state.attributes + assert MediaPlayerEntityFeature.VOLUME_STEP in attrs[ATTR_SUPPORTED_FEATURES] + assert ( + MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE + not in attrs[ATTR_SUPPORTED_FEATURES] + ) + + @pytest.mark.parametrize( ("power_state", "play_state", "media_player_state"), [ diff --git a/tests/components/cambridge_audio/test_select.py b/tests/components/cambridge_audio/test_select.py index 473c4027163..73359aaa2b7 100644 --- a/tests/components/cambridge_audio/test_select.py +++ b/tests/components/cambridge_audio/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, diff --git a/tests/components/cambridge_audio/test_switch.py b/tests/components/cambridge_audio/test_switch.py index 3192f198d1f..44f7379f22f 100644 --- a/tests/components/cambridge_audio/test_switch.py +++ b/tests/components/cambridge_audio/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, Platform diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index b529ee3e9b9..dcc02cf99fe 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -165,13 +165,15 @@ async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: async def stream_source(self) -> str | None: return STREAM_SOURCE - class SyncCamera(BaseCamera): - """Mock Camera with native sync WebRTC support.""" + class AsyncNoCandidateCamera(BaseCamera): + """Mock Camera with native async WebRTC support but not implemented candidate support.""" - _attr_name = "Sync" + _attr_name = "Async No Candidate" - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - return WEBRTC_ANSWER + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: + send_message(WebRTCAnswer(WEBRTC_ANSWER)) class AsyncCamera(BaseCamera): """Mock Camera with native async WebRTC support.""" @@ -221,7 +223,10 @@ async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: ), ) setup_test_component_platform( - hass, camera.DOMAIN, [SyncCamera(), AsyncCamera()], from_config_entry=True + hass, + camera.DOMAIN, + [AsyncNoCandidateCamera(), AsyncCamera()], + from_config_entry=True, ) mock_platform(hass, f"{domain}.config_flow", Mock()) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 7fd469fa51a..7c56d142920 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -27,7 +27,6 @@ from homeassistant.components.camera.helper import get_camera_from_entity_id from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_PLATFORM, EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) @@ -238,6 +237,7 @@ async def test_snapshot_service( expected_filename: str, expected_issues: list, snapshot: SnapshotAssertion, + issue_registry: ir.IssueRegistry, ) -> None: """Test snapshot service.""" mopen = mock_open() @@ -266,8 +266,6 @@ async def test_snapshot_service( assert len(mock_write.mock_calls) == 1 assert mock_write.mock_calls[0][1][0] == b"Test" - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 + len(expected_issues) for expected_issue in expected_issues: issue = issue_registry.async_get_issue(DOMAIN, expected_issue) assert issue is not None @@ -639,6 +637,7 @@ async def test_record_service( expected_filename: str, expected_issues: list, snapshot: SnapshotAssertion, + issue_registry: ir.IssueRegistry, ) -> None: """Test record service.""" with ( @@ -667,8 +666,6 @@ async def test_record_service( ANY, expected_filename, duration=30, lookback=0 ) - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 + len(expected_issues) for expected_issue in expected_issues: issue = issue_registry.async_get_issue(DOMAIN, expected_issue) assert issue is not None @@ -969,24 +966,19 @@ async def test_camera_capabilities_webrtc( """Test WebRTC camera capabilities.""" await _test_capabilities( - hass, hass_ws_client, "camera.sync", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} + hass, hass_ws_client, "camera.async", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} ) -@pytest.mark.parametrize( - ("entity_id", "expect_native_async_webrtc"), - [("camera.sync", False), ("camera.async", True)], -) @pytest.mark.usefixtures("mock_test_webrtc_cameras", "register_test_provider") async def test_webrtc_provider_not_added_for_native_webrtc( - hass: HomeAssistant, entity_id: str, expect_native_async_webrtc: bool + hass: HomeAssistant, ) -> None: """Test that a WebRTC provider is not added to a camera when the camera has native WebRTC support.""" - camera_obj = get_camera_from_entity_id(hass, entity_id) + camera_obj = get_camera_from_entity_id(hass, "camera.async") assert camera_obj assert camera_obj._webrtc_provider is None - assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc - assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc + assert camera_obj._supports_native_async_webrtc is True @pytest.mark.usefixtures("mock_camera", "mock_stream_source") @@ -1017,14 +1009,12 @@ async def test_camera_capabilities_changing_non_native_support( @pytest.mark.usefixtures("mock_test_webrtc_cameras") -@pytest.mark.parametrize(("entity_id"), ["camera.sync", "camera.async"]) async def test_camera_capabilities_changing_native_support( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - entity_id: str, ) -> None: """Test WebRTC camera capabilities.""" - cam = get_camera_from_entity_id(hass, entity_id) + cam = get_camera_from_entity_id(hass, "camera.async") assert cam.supported_features == camera.CameraEntityFeature.STREAM await _test_capabilities( @@ -1036,27 +1026,3 @@ async def test_camera_capabilities_changing_native_support( await hass.async_block_till_done() await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) - - -@pytest.mark.usefixtures("enable_custom_integrations") -async def test_deprecated_frontend_stream_type_logs( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test using (_attr_)frontend_stream_type will log.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - for entity_id in ( - "camera.property_frontend_stream_type", - "camera.attr_frontend_stream_type", - ): - camera_obj = get_camera_from_entity_id(hass, entity_id) - assert camera_obj.frontend_stream_type == StreamType.WEB_RTC - - assert ( - "Detected that custom integration 'test' is overwriting the 'frontend_stream_type' property in the PropertyFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6," - ) in caplog.text - assert ( - "Detected that custom integration 'test' is setting the '_attr_frontend_stream_type' attribute in the AttrFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6," - ) in caplog.text diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index a7c6d889409..e6b13afc171 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -1,7 +1,6 @@ """Test camera WebRTC.""" -from collections.abc import AsyncGenerator, Generator -import logging +from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -10,9 +9,7 @@ from webrtc_models import RTCIceCandidate, RTCIceCandidateInit, RTCIceServer from homeassistant.components.camera import ( DATA_ICE_SERVERS, - DOMAIN as CAMERA_DOMAIN, Camera, - CameraEntityFeature, CameraWebRTCProvider, StreamType, WebRTCAnswer, @@ -20,30 +17,17 @@ from homeassistant.components.camera import ( WebRTCError, WebRTCMessage, WebRTCSendMessage, - async_get_supported_legacy_provider, async_register_ice_servers, - async_register_rtsp_to_web_rtc_provider, async_register_webrtc_provider, get_camera_from_entity_id, ) from homeassistant.components.websocket_api import TYPE_RESULT -from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback from homeassistant.core_config import async_process_ha_core_config -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider -from tests.common import ( - MockConfigEntry, - MockModule, - mock_config_flow, - mock_integration, - mock_platform, - setup_test_component_platform, -) from tests.typing import WebSocketGenerator WEBRTC_OFFER = "v=0\r\n" @@ -60,84 +44,6 @@ class Go2RTCProvider(SomeTestProvider): return "go2rtc" -class MockCamera(Camera): - """Mock Camera Entity.""" - - _attr_name = "Test" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - - def __init__(self) -> None: - """Initialize the mock entity.""" - super().__init__() - self._sync_answer: str | None | Exception = WEBRTC_ANSWER - - def set_sync_answer(self, value: str | None | Exception) -> None: - """Set sync offer answer.""" - self._sync_answer = value - - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - """Handle the WebRTC offer and return the answer.""" - if isinstance(self._sync_answer, Exception): - raise self._sync_answer - return self._sync_answer - - async def stream_source(self) -> str | None: - """Return the source of the stream. - - This is used by cameras with CameraEntityFeature.STREAM - and StreamType.HLS. - """ - return "rtsp://stream" - - -@pytest.fixture -async def init_test_integration( - hass: HomeAssistant, -) -> MockCamera: - """Initialize components.""" - - entry = MockConfigEntry(domain=TEST_INTEGRATION_DOMAIN) - entry.add_to_hass(hass) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups( - config_entry, [CAMERA_DOMAIN] - ) - return True - - async def async_unload_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Unload test config entry.""" - await hass.config_entries.async_forward_entry_unload( - config_entry, CAMERA_DOMAIN - ) - return True - - mock_integration( - hass, - MockModule( - TEST_INTEGRATION_DOMAIN, - async_setup_entry=async_setup_entry_init, - async_unload_entry=async_unload_entry_init, - ), - ) - test_camera = MockCamera() - setup_test_component_platform( - hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True - ) - mock_platform(hass, f"{TEST_INTEGRATION_DOMAIN}.config_flow", Mock()) - - with mock_config_flow(TEST_INTEGRATION_DOMAIN, ConfigFlow): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return test_camera - - @pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_async_register_webrtc_provider( hass: HomeAssistant, @@ -305,7 +211,6 @@ async def test_ws_get_client_config( }, ], }, - "getCandidatesUpfront": False, } @callback @@ -344,30 +249,6 @@ async def test_ws_get_client_config( }, ], }, - "getCandidatesUpfront": False, - } - - -@pytest.mark.usefixtures("mock_test_webrtc_cameras") -async def test_ws_get_client_config_sync_offer( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test get WebRTC client config, when camera is supporting sync offer.""" - await async_setup_component(hass, "camera", {}) - await hass.async_block_till_done() - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.sync"} - ) - msg = await client.receive_json() - - # Assert WebSocket response - assert msg["type"] == TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "configuration": {}, - "getCandidatesUpfront": True, } @@ -394,7 +275,6 @@ async def test_ws_get_client_config_custom_config( assert msg["success"] assert msg["result"] == { "configuration": {"iceServers": [{"urls": ["stun:custom_stun_server:3478"]}]}, - "getCandidatesUpfront": False, } @@ -427,21 +307,6 @@ async def provide_webrtc_answer(stream_source: str, offer: str, stream_id: str) return WEBRTC_ANSWER -@pytest.fixture(name="mock_rtsp_to_webrtc") -def mock_rtsp_to_webrtc_fixture( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> Generator[Mock]: - """Fixture that registers a mock rtsp to webrtc provider.""" - mock_provider = Mock(side_effect=provide_webrtc_answer) - unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider) - assert ( - "async_register_rtsp_to_web_rtc_provider is a deprecated function which will" - " be removed in HA Core 2025.6. Use async_register_webrtc_provider instead" - ) in caplog.text - yield mock_provider - unsub() - - @pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_websocket_webrtc_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -643,144 +508,6 @@ async def test_websocket_webrtc_offer_missing_offer( assert response["error"]["code"] == "invalid_format" -@pytest.mark.parametrize( - ("error", "expected_message"), - [ - (ValueError("value error"), "value error"), - (HomeAssistantError("offer failed"), "offer failed"), - (TimeoutError(), "Timeout handling WebRTC offer"), - ], -) -async def test_websocket_webrtc_offer_failure( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_test_integration: MockCamera, - error: Exception, - expected_message: str, -) -> None: - """Test WebRTC stream that fails handling the offer.""" - client = await hass_ws_client(hass) - init_test_integration.set_sync_answer(error) - - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.test", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Error - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "error", - "code": "webrtc_offer_failed", - "message": expected_message, - } - - -@pytest.mark.usefixtures("mock_test_webrtc_cameras") -async def test_websocket_webrtc_offer_sync( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test sync WebRTC stream offer.""" - client = await hass_ws_client(hass) - - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.sync", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - - assert ( - "tests.components.camera.conftest", - logging.WARNING, - ( - "async_handle_web_rtc_offer was called from camera, this is a deprecated " - "function which will be removed in HA Core 2025.6. Use " - "async_handle_async_webrtc_offer instead" - ), - ) in caplog.record_tuples - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == {"type": "answer", "answer": WEBRTC_ANSWER} - - -async def test_websocket_webrtc_offer_sync_no_answer( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - caplog: pytest.LogCaptureFixture, - init_test_integration: MockCamera, -) -> None: - """Test sync WebRTC stream offer with no answer.""" - client = await hass_ws_client(hass) - init_test_integration.set_sync_answer(None) - - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.test", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "error", - "code": "webrtc_offer_failed", - "message": "No answer on WebRTC offer", - } - assert ( - "homeassistant.components.camera", - logging.ERROR, - "Error handling WebRTC offer: No answer", - ) in caplog.record_tuples - - @pytest.mark.usefixtures("mock_camera") async def test_websocket_webrtc_offer_invalid_stream_type( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -804,45 +531,6 @@ async def test_websocket_webrtc_offer_invalid_stream_type( } -@pytest.mark.usefixtures("mock_camera", "mock_stream_source") -async def test_rtsp_to_webrtc_offer( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_rtsp_to_webrtc: Mock, -) -> None: - """Test creating a webrtc offer from an rstp provider.""" - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "answer", - "answer": WEBRTC_ANSWER, - } - - assert mock_rtsp_to_webrtc.called - - @pytest.fixture(name="mock_hls_stream_source") async def mock_hls_stream_source_fixture() -> AsyncGenerator[AsyncMock]: """Fixture to create an HLS stream source.""" @@ -853,117 +541,6 @@ async def mock_hls_stream_source_fixture() -> AsyncGenerator[AsyncMock]: yield mock_hls_stream_source -@pytest.mark.usefixtures("mock_camera", "mock_stream_source") -async def test_rtsp_to_webrtc_provider_unregistered( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test creating a webrtc offer from an rstp provider.""" - mock_provider = Mock(side_effect=provide_webrtc_answer) - unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider) - - client = await hass_ws_client(hass) - - # Registered provider can handle the WebRTC offer - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "answer", - "answer": WEBRTC_ANSWER, - } - - assert mock_provider.called - mock_provider.reset_mock() - - # Unregister provider, then verify the WebRTC offer cannot be handled - unsub() - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - assert response.get("type") == TYPE_RESULT - assert not response["success"] - assert response["error"] == { - "code": "webrtc_offer_failed", - "message": "Camera does not support WebRTC, frontend_stream_types={}", - } - - assert not mock_provider.called - - -@pytest.mark.usefixtures("mock_camera", "mock_stream_source") -async def test_rtsp_to_webrtc_offer_not_accepted( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test a provider that can't satisfy the rtsp to webrtc offer.""" - - async def provide_none( - stream_source: str, offer: str, stream_id: str - ) -> str | None: - """Simulate a provider that can't accept the offer.""" - return None - - mock_provider = Mock(side_effect=provide_none) - unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider) - client = await hass_ws_client(hass) - - # Registered provider can handle the WebRTC offer - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "error", - "code": "webrtc_offer_failed", - "message": "Camera does not support WebRTC", - } - - assert mock_provider.called - - unsub() - - @pytest.mark.parametrize( ("frontend_candidate", "expected_candidate"), [ @@ -1069,7 +646,7 @@ async def test_ws_webrtc_candidate_not_supported( await client.send_json_auto_id( { "type": "camera/webrtc/candidate", - "entity_id": "camera.sync", + "entity_id": "camera.async_no_candidate", "session_id": "session_id", "candidate": {"candidate": "candidate"}, } @@ -1224,79 +801,3 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None: "session_id", RTCIceCandidateInit("candidate") ) provider.async_close_session("session_id") - - -@pytest.mark.usefixtures("mock_camera") -async def test_repair_issue_legacy_provider( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repair issue created for legacy provider.""" - # Ensure no issue if no provider is registered - assert not issue_registry.async_get_issue( - "camera", "legacy_webrtc_provider_mock_domain" - ) - - # Register a legacy provider - legacy_provider = Mock(side_effect=provide_webrtc_answer) - unsub_legacy_provider = async_register_rtsp_to_web_rtc_provider( - hass, "mock_domain", legacy_provider - ) - await hass.async_block_till_done() - - # Ensure no issue if only legacy provider is registered - assert not issue_registry.async_get_issue( - "camera", "legacy_webrtc_provider_mock_domain" - ) - - provider = Go2RTCProvider() - unsub_go2rtc_provider = async_register_webrtc_provider(hass, provider) - await hass.async_block_till_done() - - # Ensure issue when legacy and builtin provider are registered - issue = issue_registry.async_get_issue( - "camera", "legacy_webrtc_provider_mock_domain" - ) - assert issue - assert issue.is_fixable is False - assert issue.is_persistent is False - assert issue.issue_domain == "mock_domain" - assert issue.learn_more_url == "https://www.home-assistant.io/integrations/go2rtc/" - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.issue_id == "legacy_webrtc_provider_mock_domain" - assert issue.translation_key == "legacy_webrtc_provider" - assert issue.translation_placeholders == { - "legacy_integration": "mock_domain", - "builtin_integration": "go2rtc", - } - - unsub_legacy_provider() - unsub_go2rtc_provider() - - -@pytest.mark.usefixtures("mock_camera", "register_test_provider", "mock_rtsp_to_webrtc") -async def test_no_repair_issue_without_new_provider( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repair issue not created if no go2rtc provider exists.""" - assert not issue_registry.async_get_issue( - "camera", "legacy_webrtc_provider_mock_domain" - ) - - -@pytest.mark.usefixtures("mock_camera", "mock_rtsp_to_webrtc") -async def test_registering_same_legacy_provider( - hass: HomeAssistant, -) -> None: - """Test registering the same legacy provider twice.""" - legacy_provider = Mock(side_effect=provide_webrtc_answer) - with pytest.raises(ValueError, match="Provider already registered"): - async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", legacy_provider) - - -@pytest.mark.usefixtures("mock_hls_stream_source", "mock_camera", "mock_rtsp_to_webrtc") -async def test_get_not_supported_legacy_provider(hass: HomeAssistant) -> None: - """Test getting a not supported legacy provider.""" - camera = get_camera_from_entity_id(hass, "camera.demo_camera") - assert await async_get_supported_legacy_provider(hass, camera) is None diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr index a3cda75463f..d71672ce40c 100644 --- a/tests/components/ccm15/snapshots/test_climate.ambr +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -49,6 +49,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.0', @@ -105,6 +106,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.1', @@ -241,6 +243,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.0', @@ -297,6 +300,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.1', diff --git a/tests/components/ccm15/test_diagnostics.py b/tests/components/ccm15/test_diagnostics.py index f6f0d75c4e3..ae876694c0c 100644 --- a/tests/components/ccm15/test_diagnostics.py +++ b/tests/components/ccm15/test_diagnostics.py @@ -1,7 +1,7 @@ """Test CCM15 diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ccm15.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT diff --git a/tests/components/chacon_dio/snapshots/test_cover.ambr b/tests/components/chacon_dio/snapshots/test_cover.ambr index afac3359410..79d09957600 100644 --- a/tests/components/chacon_dio/snapshots/test_cover.ambr +++ b/tests/components/chacon_dio/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'chacon_dio', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'L4HActuator_idmock1', diff --git a/tests/components/chacon_dio/snapshots/test_switch.ambr b/tests/components/chacon_dio/snapshots/test_switch.ambr index a2620005531..ab8ef0fef36 100644 --- a/tests/components/chacon_dio/snapshots/test_switch.ambr +++ b/tests/components/chacon_dio/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'chacon_dio', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'L4HActuator_idmock1', diff --git a/tests/components/climate/common.py b/tests/components/climate/common.py index 8f5834d9180..ca214ec2d70 100644 --- a/tests/components/climate/common.py +++ b/tests/components/climate/common.py @@ -6,7 +6,6 @@ components. Instead call the service directly. from homeassistant.components.climate import ( _LOGGER, - ATTR_AUX_HEAT, ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_MODE, @@ -16,7 +15,6 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN, - SERVICE_SET_AUX_HEAT, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, @@ -62,31 +60,6 @@ def set_preset_mode( hass.services.call(DOMAIN, SERVICE_SET_PRESET_MODE, data) -async def async_set_aux_heat( - hass: HomeAssistant, aux_heat: bool, entity_id: str = ENTITY_MATCH_ALL -) -> None: - """Turn all or specified climate devices auxiliary heater on.""" - data = {ATTR_AUX_HEAT: aux_heat} - - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - await hass.services.async_call(DOMAIN, SERVICE_SET_AUX_HEAT, data, blocking=True) - - -@bind_hass -def set_aux_heat( - hass: HomeAssistant, aux_heat: bool, entity_id: str = ENTITY_MATCH_ALL -) -> None: - """Turn all or specified climate devices auxiliary heater on.""" - data = {ATTR_AUX_HEAT: aux_heat} - - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_AUX_HEAT, data) - - async def async_set_temperature( hass: HomeAssistant, temperature: float | None = None, diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 8900a9faefa..a81efa1640c 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -37,21 +37,14 @@ from homeassistant.components.climate.const import ( SWING_HORIZONTAL_ON, ClimateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, MockEntity, - MockModule, - MockPlatform, async_mock_service, - mock_integration, - mock_platform, setup_test_component_platform, ) @@ -500,255 +493,6 @@ async def test_sync_toggle(hass: HomeAssistant) -> None: assert climate.toggle.called -ISSUE_TRACKER = "https://blablabla.com" - - -@pytest.mark.parametrize( - ( - "manifest_extra", - "translation_key", - "translation_placeholders_extra", - "report", - "module", - ), - [ - ( - {}, - "deprecated_climate_aux_no_url", - {}, - "report it to the author of the 'test' custom integration", - "custom_components.test.climate", - ), - ( - {"issue_tracker": ISSUE_TRACKER}, - "deprecated_climate_aux_url_custom", - {"issue_tracker": ISSUE_TRACKER}, - "create a bug report at https://blablabla.com", - "custom_components.test.climate", - ), - ], -) -async def test_issue_aux_property_deprecated( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - config_flow_fixture: None, - manifest_extra: dict[str, str], - translation_key: str, - translation_placeholders_extra: dict[str, str], - report: str, - module: str, - issue_registry: ir.IssueRegistry, -) -> None: - """Test the issue is raised on deprecated auxiliary heater attributes.""" - - class MockClimateEntityWithAux(MockClimateEntity): - """Mock climate class with mocked aux heater.""" - - _attr_supported_features = ( - ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE - ) - - @property - def is_aux_heat(self) -> bool | None: - """Return true if aux heater. - - Requires ClimateEntityFeature.AUX_HEAT. - """ - return True - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - await self.hass.async_add_executor_job(self.turn_aux_heat_on) - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - await self.hass.async_add_executor_job(self.turn_aux_heat_off) - - # Fake the module is custom component or built in - MockClimateEntityWithAux.__module__ = module - - climate_entity = MockClimateEntityWithAux( - name="Testing", - entity_id="climate.testing", - ) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_climate_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, - ) -> None: - """Set up test weather platform via config entry.""" - async_add_entities([climate_entity]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - partial_manifest=manifest_extra, - ), - built_in=False, - ) - mock_platform( - hass, - "test.climate", - MockPlatform(async_setup_entry=async_setup_entry_climate_platform), - ) - - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert climate_entity.state == HVACMode.HEAT - - issue = issue_registry.async_get_issue("climate", "deprecated_climate_aux_test") - assert issue - assert issue.issue_domain == "test" - assert issue.issue_id == "deprecated_climate_aux_test" - assert issue.translation_key == translation_key - assert ( - issue.translation_placeholders - == {"platform": "test"} | translation_placeholders_extra - ) - - assert ( - "test::MockClimateEntityWithAux implements the `is_aux_heat` property or uses " - "the auxiliary heater methods in a subclass of ClimateEntity which is deprecated " - f"and will be unsupported from Home Assistant 2025.4. Please {report}" - ) in caplog.text - - # Assert we only log warning once - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SET_TEMPERATURE, - { - "entity_id": "climate.test", - "temperature": "25", - }, - blocking=True, - ) - await hass.async_block_till_done() - - assert ("implements the `is_aux_heat` property") not in caplog.text - - -@pytest.mark.parametrize( - ( - "manifest_extra", - "translation_key", - "translation_placeholders_extra", - "report", - "module", - ), - [ - ( - {"issue_tracker": ISSUE_TRACKER}, - "deprecated_climate_aux_url", - {"issue_tracker": ISSUE_TRACKER}, - "create a bug report at https://blablabla.com", - "homeassistant.components.test.climate", - ), - ], -) -async def test_no_issue_aux_property_deprecated_for_core( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, - manifest_extra: dict[str, str], - translation_key: str, - translation_placeholders_extra: dict[str, str], - report: str, - module: str, - issue_registry: ir.IssueRegistry, -) -> None: - """Test the no issue on deprecated auxiliary heater attributes for core integrations.""" - - class MockClimateEntityWithAux(MockClimateEntity): - """Mock climate class with mocked aux heater.""" - - _attr_supported_features = ClimateEntityFeature.AUX_HEAT - - @property - def is_aux_heat(self) -> bool | None: - """Return true if aux heater. - - Requires ClimateEntityFeature.AUX_HEAT. - """ - return True - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - await self.hass.async_add_executor_job(self.turn_aux_heat_on) - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - await self.hass.async_add_executor_job(self.turn_aux_heat_off) - - # Fake the module is custom component or built in - MockClimateEntityWithAux.__module__ = module - - climate_entity = MockClimateEntityWithAux( - name="Testing", - entity_id="climate.testing", - ) - - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - assert climate_entity.state == HVACMode.HEAT - - issue = issue_registry.async_get_issue("climate", "deprecated_climate_aux_test") - assert not issue - - assert ( - "test::MockClimateEntityWithAux implements the `is_aux_heat` property or uses " - "the auxiliary heater methods in a subclass of ClimateEntity which is deprecated " - f"and will be unsupported from Home Assistant 2024.10. Please {report}" - ) not in caplog.text - - -async def test_no_issue_no_aux_property( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test the issue is raised on deprecated auxiliary heater attributes.""" - - climate_entity = MockClimateEntity( - name="Testing", - entity_id="climate.testing", - ) - - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - assert await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - assert climate_entity.state == HVACMode.HEAT - - assert len(issue_registry.issues) == 0 - - assert ( - "test::MockClimateEntityWithAux implements the `is_aux_heat` property or uses " - "the auxiliary heater methods in a subclass of ClimateEntity which is deprecated " - "and will be unsupported from Home Assistant 2024.10." - ) not in caplog.text - - async def test_humidity_validation( hass: HomeAssistant, register_test_integration: MockConfigEntry, diff --git a/tests/components/climate/test_significant_change.py b/tests/components/climate/test_significant_change.py index 7d709090357..6fa53c306db 100644 --- a/tests/components/climate/test_significant_change.py +++ b/tests/components/climate/test_significant_change.py @@ -3,7 +3,6 @@ import pytest from homeassistant.components.climate import ( - ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -37,8 +36,6 @@ async def test_significant_state_change(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("unit_system", "old_attrs", "new_attrs", "expected_result"), [ - (METRIC, {ATTR_AUX_HEAT: "old_value"}, {ATTR_AUX_HEAT: "old_value"}, False), - (METRIC, {ATTR_AUX_HEAT: "old_value"}, {ATTR_AUX_HEAT: "new_value"}, True), (METRIC, {ATTR_FAN_MODE: "old_value"}, {ATTR_FAN_MODE: "old_value"}, False), (METRIC, {ATTR_FAN_MODE: "old_value"}, {ATTR_FAN_MODE: "new_value"}, True), ( diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 8399e69ab09..c9e0f37829a 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -24,20 +24,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component -from homeassistant.util.aiohttp import MockStreamReader +from homeassistant.util.aiohttp import MockStreamReaderChunked from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator -class MockStreamReaderChunked(MockStreamReader): - """Mock a stream reader with simulated chunked data.""" - - async def readchunk(self) -> tuple[bytes, bool]: - """Read bytes.""" - return (self._content.read(), False) - - @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, @@ -160,28 +152,32 @@ async def test_agents_list_backups( "addons": [], "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", - "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "date": "2024-11-22T11:48:48.727189+01:00", "extra_metadata": {}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "failed_agent_ids": [], "with_automatic_settings": None, }, { "addons": [], "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aed", - "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "date": "2024-11-22T11:48:48.727189+01:00", "extra_metadata": {}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "failed_agent_ids": [], "with_automatic_settings": None, }, ] @@ -224,14 +220,16 @@ async def test_agents_list_backups_fail_cloud( "addons": [], "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", - "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "date": "2024-11-22T11:48:48.727189+01:00", "extra_metadata": {}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "failed_agent_ids": [], "with_automatic_settings": None, }, ), diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 52457fe558c..283e2ff39f1 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -468,7 +468,10 @@ async def test_async_create_repair_issue_known( await cloud.client.async_create_repair_issue( identifier=identifier, translation_key=translation_key, - placeholders={"custom_domains": "example.com"}, + placeholders={ + "account_url": "http://example.org", + "custom_domains": "example.com", + }, severity="warning", ) issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) @@ -479,19 +482,53 @@ async def test_async_create_repair_issue_unknown( cloud: MagicMock, mock_cloud_setup: None, issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test not creating repair issue for unknown repairs.""" identifier = "abc123" - with pytest.raises( - ValueError, - match="Invalid translation key unknown_translation_key", - ): - await cloud.client.async_create_repair_issue( - identifier=identifier, - translation_key="unknown_translation_key", - placeholders={"custom_domains": "example.com"}, - severity="error", - ) + await cloud.client.async_create_repair_issue( + identifier=identifier, + translation_key="unknown_translation_key", + placeholders={"custom_domains": "example.com"}, + severity="error", + ) + assert ( + "Invalid translation key unknown_translation_key for repair issue abc123" + in caplog.text + ) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is None + + +async def test_async_delete_repair_issue( + cloud: MagicMock, + mock_cloud_setup: None, + issue_registry: ir.IssueRegistry, +) -> None: + """Test delete repair issue.""" + identifier = "test_identifier" + issue_registry.issues[(DOMAIN, identifier)] = ir.IssueEntry( + active=True, + breaks_in_ha_version=None, + created=dt_util.utcnow(), + data={}, + dismissed_version=None, + domain=DOMAIN, + is_fixable=False, + is_persistent=True, + issue_domain=None, + issue_id=identifier, + learn_more_url=None, + severity="warning", + translation_key="test_translation_key", + translation_placeholders=None, + ) + + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is not None + + await cloud.client.async_delete_repair_issue(identifier=identifier) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) assert issue is None diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 2722445445e..b5cce286ba2 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from freezegun.api import FrozenDateTimeFactory -from hass_nabucasa import AlreadyConnectedError, thingtalk +from hass_nabucasa import AlreadyConnectedError from hass_nabucasa.auth import ( InvalidTotpCode, MFARequired, @@ -1745,70 +1745,6 @@ async def test_enable_alexa_state_report_fail( assert response["error"]["code"] == "alexa_relink" -async def test_thingtalk_convert( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - setup_cloud: None, -) -> None: - """Test that we can convert a query.""" - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.cloud.http_api.thingtalk.async_convert", - return_value={"hello": "world"}, - ): - await client.send_json( - {"id": 5, "type": "cloud/thingtalk/convert", "query": "some-data"} - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"hello": "world"} - - -async def test_thingtalk_convert_timeout( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - setup_cloud: None, -) -> None: - """Test that we can convert a query.""" - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.cloud.http_api.thingtalk.async_convert", - side_effect=TimeoutError, - ): - await client.send_json( - {"id": 5, "type": "cloud/thingtalk/convert", "query": "some-data"} - ) - response = await client.receive_json() - - assert not response["success"] - assert response["error"]["code"] == "timeout" - - -async def test_thingtalk_convert_internal( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - setup_cloud: None, -) -> None: - """Test that we can convert a query.""" - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.cloud.http_api.thingtalk.async_convert", - side_effect=thingtalk.ThingTalkConversionError("Did not understand"), - ): - await client.send_json( - {"id": 5, "type": "cloud/thingtalk/convert", "query": "some-data"} - ) - response = await client.receive_json() - - assert not response["success"] - assert response["error"]["code"] == "unknown_error" - assert response["error"]["message"] == "Did not understand" - - async def test_tts_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/co2signal/snapshots/test_sensor.ambr b/tests/components/co2signal/snapshots/test_sensor.ambr index 1e241735102..03f6123ec7c 100644 --- a/tests/components/co2signal/snapshots/test_sensor.ambr +++ b/tests/components/co2signal/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'CO2 intensity', 'platform': 'co2signal', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'carbon_intensity', 'unique_id': '904a74160aa6f335526706bee85dfb83_co2intensity', @@ -82,6 +83,7 @@ 'original_name': 'Grid fossil fuel percentage', 'platform': 'co2signal', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fossil_fuel_percentage', 'unique_id': '904a74160aa6f335526706bee85dfb83_fossilFuelPercentage', diff --git a/tests/components/co2signal/test_diagnostics.py b/tests/components/co2signal/test_diagnostics.py index 3d5e1a0580b..3ede845f01f 100644 --- a/tests/components/co2signal/test_diagnostics.py +++ b/tests/components/co2signal/test_diagnostics.py @@ -1,7 +1,7 @@ """Test the CO2Signal diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py index fddda17f3ed..2154782f62d 100644 --- a/tests/components/co2signal/test_sensor.py +++ b/tests/components/co2signal/test_sensor.py @@ -11,7 +11,7 @@ from aioelectricitymaps import ( ) from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/coinbase/test_diagnostics.py b/tests/components/coinbase/test_diagnostics.py index 0e06c172c37..98936f47e48 100644 --- a/tests/components/coinbase/test_diagnostics.py +++ b/tests/components/coinbase/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py index 1e5e85cd26e..8ac77505590 100644 --- a/tests/components/comelit/conftest.py +++ b/tests/components/comelit/conftest.py @@ -57,7 +57,7 @@ def mock_serial_bridge() -> Generator[AsyncMock]: @pytest.fixture -def mock_serial_bridge_config_entry() -> Generator[MockConfigEntry]: +def mock_serial_bridge_config_entry() -> MockConfigEntry: """Mock a Comelit config entry for Comelit bridge.""" return MockConfigEntry( domain=COMELIT_DOMAIN, @@ -94,7 +94,7 @@ def mock_vedo() -> Generator[AsyncMock]: @pytest.fixture -def mock_vedo_config_entry() -> Generator[MockConfigEntry]: +def mock_vedo_config_entry() -> MockConfigEntry: """Mock a Comelit config entry for Comelit vedo.""" return MockConfigEntry( domain=COMELIT_DOMAIN, diff --git a/tests/components/comelit/snapshots/test_climate.ambr b/tests/components/comelit/snapshots/test_climate.ambr index 0233359bc45..c55836793f7 100644 --- a/tests/components/comelit/snapshots/test_climate.ambr +++ b/tests/components/comelit/snapshots/test_climate.ambr @@ -6,13 +6,16 @@ 'area_id': None, 'capabilities': dict({ 'hvac_modes': list([ - , , , , ]), 'max_temp': 30, 'min_temp': 5, + 'preset_modes': list([ + 'automatic', + 'manual', + ]), 'target_temp_step': 0.1, }), 'config_entry_id': , @@ -37,8 +40,9 @@ 'original_name': None, 'platform': 'comelit', 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'thermostat', 'unique_id': 'serial_bridge_config_entry_id-0', 'unit_of_measurement': None, }) @@ -50,14 +54,18 @@ 'friendly_name': 'Climate0', 'hvac_action': , 'hvac_modes': list([ - , , , , ]), 'max_temp': 30, 'min_temp': 5, - 'supported_features': , + 'preset_mode': 'manual', + 'preset_modes': list([ + 'automatic', + 'manual', + ]), + 'supported_features': , 'target_temp_step': 0.1, 'temperature': 5.0, }), diff --git a/tests/components/comelit/snapshots/test_cover.ambr b/tests/components/comelit/snapshots/test_cover.ambr index 17189344cd1..a0575a19d2b 100644 --- a/tests/components/comelit/snapshots/test_cover.ambr +++ b/tests/components/comelit/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'comelit', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'serial_bridge_config_entry_id-0', diff --git a/tests/components/comelit/snapshots/test_humidifier.ambr b/tests/components/comelit/snapshots/test_humidifier.ambr index ffe53d09c5d..587bc8513f2 100644 --- a/tests/components/comelit/snapshots/test_humidifier.ambr +++ b/tests/components/comelit/snapshots/test_humidifier.ambr @@ -34,6 +34,7 @@ 'original_name': 'Dehumidifier', 'platform': 'comelit', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'dehumidifier', 'unique_id': 'serial_bridge_config_entry_id-0-dehumidifier', @@ -100,6 +101,7 @@ 'original_name': 'Humidifier', 'platform': 'comelit', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'humidifier', 'unique_id': 'serial_bridge_config_entry_id-0-humidifier', diff --git a/tests/components/comelit/snapshots/test_light.ambr b/tests/components/comelit/snapshots/test_light.ambr index c60c962e23d..734ce177673 100644 --- a/tests/components/comelit/snapshots/test_light.ambr +++ b/tests/components/comelit/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': None, 'platform': 'comelit', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'serial_bridge_config_entry_id-0', diff --git a/tests/components/comelit/snapshots/test_sensor.ambr b/tests/components/comelit/snapshots/test_sensor.ambr index dabae2a1bf0..602b9a9cad3 100644 --- a/tests/components/comelit/snapshots/test_sensor.ambr +++ b/tests/components/comelit/snapshots/test_sensor.ambr @@ -41,6 +41,7 @@ 'original_name': None, 'platform': 'comelit', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'zone_status', 'unique_id': 'vedo_config_entry_id-0', diff --git a/tests/components/comelit/snapshots/test_switch.ambr b/tests/components/comelit/snapshots/test_switch.ambr index eddecfabb7a..d41394ed245 100644 --- a/tests/components/comelit/snapshots/test_switch.ambr +++ b/tests/components/comelit/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'comelit', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'serial_bridge_config_entry_id-other-0', diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py index a380faeb5c0..53a84fbc6b8 100644 --- a/tests/components/comelit/test_climate.py +++ b/tests/components/comelit/test_climate.py @@ -7,16 +7,23 @@ from aiocomelit.api import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE, WATT from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_HVAC_MODE, + ATTR_PRESET_MODE, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, HVACMode, ) -from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.components.comelit.const import ( + PRESET_MODE_AUTO, + PRESET_MODE_MANUAL, + 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 @@ -139,7 +146,7 @@ async def test_climate_data_update_bad_data( status=0, human_status="off", type="climate", - val="bad_data", + val="bad_data", # type: ignore[arg-type] protected=0, zone="Living room", power=0.0, @@ -273,10 +280,113 @@ async def test_climate_hvac_mode_when_off( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.AUTO}, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.COOL}, blocking=True, ) mock_serial_bridge.set_clima_status.assert_called() assert (state := hass.states.get(ENTITY_ID)) - assert state.state == HVACMode.AUTO + assert state.state == HVACMode.COOL + + +async def test_climate_preset_mode( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate preset 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 + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_MANUAL + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_AUTO}, + 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] == 20.0 + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_AUTO + + +async def test_climate_preset_mode_when_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate preset 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 + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_MANUAL + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + 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_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_AUTO}, + 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_remove_stale( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test removal of stale climate entities.""" + + 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=[ + [0, 0, "O", "A", 0, 0, 0, "N"], + [650, 0, "U", "M", 500, 0, 0, "U"], + [0, 0], + ], + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + await hass.config_entries.async_reload(mock_serial_bridge_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) is None diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index dd1d1fb3836..1751a837026 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -219,3 +219,94 @@ async def test_reauth_not_successful( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_vedo_config_entry.data[CONF_PIN] == VEDO_PIN + + +async def test_reconfigure_successful( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test that the host can be reconfigured.""" + mock_serial_bridge_config_entry.add_to_hass(hass) + result = await mock_serial_bridge_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # original entry + assert mock_serial_bridge_config_entry.data[CONF_HOST] == "fake_bridge_host" + + new_host = "new_bridge_host" + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: new_host, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_serial_bridge_config_entry.data[CONF_HOST] == new_host + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (ConnectionResetError, "unknown"), + ], +) +async def test_reconfigure_fails( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test that the host can be reconfigured.""" + mock_serial_bridge_config_entry.add_to_hass(hass) + result = await mock_serial_bridge_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_serial_bridge.login.side_effect = side_effect + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.100.60", + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + assert reconfigure_result["errors"] == {"base": error} + + mock_serial_bridge.login.side_effect = None + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.100.61", + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + assert mock_serial_bridge_config_entry.data == { + CONF_HOST: "192.168.100.61", + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + } diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py index 7fb74911cc6..5513f3c4e25 100644 --- a/tests/components/comelit/test_cover.py +++ b/tests/components/comelit/test_cover.py @@ -5,7 +5,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.components.comelit.const import SCAN_INTERVAL from homeassistant.components.cover import ( @@ -15,6 +15,7 @@ from homeassistant.components.cover import ( SERVICE_STOP_COVER, STATE_CLOSED, STATE_CLOSING, + STATE_OPEN, STATE_OPENING, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform @@ -94,7 +95,7 @@ async def test_cover_open( await hass.async_block_till_done() assert (state := hass.states.get(ENTITY_ID)) - assert state.state == STATE_UNKNOWN + assert state.state == STATE_OPEN async def test_cover_close( @@ -159,3 +160,36 @@ async def test_cover_stop_if_stopped( assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNKNOWN + + +async def test_cover_restore_state( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover restore state on reload.""" + + 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 + + await hass.config_entries.async_reload(mock_serial_bridge_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OPENING diff --git a/tests/components/comelit/test_diagnostics.py b/tests/components/comelit/test_diagnostics.py index cabcd0f4cac..8743c5b4b64 100644 --- a/tests/components/comelit/test_diagnostics.py +++ b/tests/components/comelit/test_diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/comelit/test_humidifier.py b/tests/components/comelit/test_humidifier.py index 4b9d6324c6e..6530d33f09b 100644 --- a/tests/components/comelit/test_humidifier.py +++ b/tests/components/comelit/test_humidifier.py @@ -7,7 +7,7 @@ from aiocomelit.api import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE, WATT from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.comelit.const import DOMAIN, SCAN_INTERVAL from homeassistant.components.humidifier import ( @@ -146,7 +146,7 @@ async def test_humidifier_data_update_bad_data( status=0, human_status="off", type="climate", - val="bad_data", + val="bad_data", # type: ignore[arg-type] protected=0, zone="Living room", power=0.0, @@ -290,3 +290,41 @@ async def test_humidifier_set_status( assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_ON + + +async def test_humidifier_dehumidifier_remove_stale( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test removal of stale humidifier/dehumidifier entities.""" + + 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=[ + [221, 0, "U", "M", 50, 0, 0, "U"], + [0, 0, "O", "A", 0, 0, 0, "N"], + [0, 0], + ], + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + await hass.config_entries.async_reload(mock_serial_bridge_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) is None diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py index 7c3cd15c135..36a191c9ee3 100644 --- a/tests/components/comelit/test_light.py +++ b/tests/components/comelit/test_light.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py index 2b857f9c94a..1bf717ca894 100644 --- a/tests/components/comelit/test_sensor.py +++ b/tests/components/comelit/test_sensor.py @@ -5,7 +5,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.components.comelit.const import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN, Platform diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py index 01efabf6b6f..31a4c4b144c 100644 --- a/tests/components/comelit/test_switch.py +++ b/tests/components/comelit/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/comelit/test_utils.py b/tests/components/comelit/test_utils.py new file mode 100644 index 00000000000..dbf4904fefe --- /dev/null +++ b/tests/components/comelit/test_utils.py @@ -0,0 +1,148 @@ +"""Tests for Comelit SimpleHome utils.""" + +from unittest.mock import AsyncMock + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, WATT +from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData +import pytest + +from homeassistant.components.climate import HVACMode +from homeassistant.components.comelit.const import DOMAIN +from homeassistant.components.humidifier import ATTR_HUMIDITY +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + +ENTITY_ID_0 = "switch.switch0" +ENTITY_ID_1 = "climate.climate0" +ENTITY_ID_2 = "humidifier.climate0_dehumidifier" +ENTITY_ID_3 = "humidifier.climate0_humidifier" + + +async def test_device_remove_stale( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test removal of stale devices with no entities.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID_1)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + assert (state := hass.states.get(ENTITY_ID_2)) + assert state.state == STATE_OFF + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + assert (state := hass.states.get(ENTITY_ID_3)) + 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=[ + [0, 0, "O", "A", 0, 0, 0, "N"], + [0, 0, "O", "A", 0, 0, 0, "N"], + [0, 0], + ], + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + await hass.config_entries.async_reload(mock_serial_bridge_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID_1)) is None + assert (state := hass.states.get(ENTITY_ID_2)) is None + assert (state := hass.states.get(ENTITY_ID_3)) is None + + +@pytest.mark.parametrize( + ("side_effect", "key", "error"), + [ + (CannotConnect, "cannot_connect", "CannotConnect()"), + (CannotRetrieveData, "cannot_retrieve_data", "CannotRetrieveData()"), + ], +) +async def test_bridge_api_call_exceptions( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + side_effect: Exception, + key: str, + error: str, +) -> None: + """Test bridge_api_call decorator for exceptions.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID_0)) + assert state.state == STATE_OFF + + mock_serial_bridge.set_device_status.side_effect = side_effect + + # Call API + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID_0}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == key + assert exc_info.value.translation_placeholders == {"error": error} + + +async def test_bridge_api_call_reauth( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test bridge_api_call decorator for reauth.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID_0)) + assert state.state == STATE_OFF + + mock_serial_bridge.set_device_status.side_effect = CannotAuthenticate + + # Call API + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID_0}, + blocking=True, + ) + + assert mock_serial_bridge_config_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") == mock_serial_bridge_config_entry.entry_id diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index 6898b44f062..a0c69765c9a 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -100,6 +100,100 @@ async def test_command_line_output(hass: HomeAssistant) -> None: assert message == await hass.async_add_executor_job(Path(filename).read_text) +async def test_command_line_output_single_command( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the command line output.""" + + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "notify": { + "command": "echo", + "name": "Test3", + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service(NOTIFY_DOMAIN, "test3") + + await hass.services.async_call( + NOTIFY_DOMAIN, "test3", {"message": "test message"}, blocking=True + ) + assert "Running command: echo, with message: test message" in caplog.text + + +async def test_command_template(hass: HomeAssistant) -> None: + """Test the command line output using template as command.""" + + with tempfile.TemporaryDirectory() as tempdirname: + filename = os.path.join(tempdirname, "message.txt") + message = "one, two, testing, testing" + hass.states.async_set("sensor.test_state", filename) + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "notify": { + "command": "cat > {{ states.sensor.test_state.state }}", + "name": "Test3", + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service(NOTIFY_DOMAIN, "test3") + + await hass.services.async_call( + NOTIFY_DOMAIN, "test3", {"message": message}, blocking=True + ) + assert message == await hass.async_add_executor_job(Path(filename).read_text) + + +async def test_command_incorrect_template( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the command line output using template as command which isn't working.""" + + message = "one, two, testing, testing" + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "notify": { + "command": "cat > {{ this template doesn't parse ", + "name": "Test3", + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service(NOTIFY_DOMAIN, "test3") + + await hass.services.async_call( + NOTIFY_DOMAIN, "test3", {"message": message}, blocking=True + ) + + assert ( + "Error rendering command template: TemplateSyntaxError: expected token" + in caplog.text + ) + + @pytest.mark.parametrize( "get_config", [ diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 6784866ea4b..c6e82976bf1 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1526,6 +1526,88 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: } +async def test_subentry_flow_abort_duplicate(hass: HomeAssistant, client) -> None: + """Test we can handle a subentry flow raising due to unique_id collision.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + return await self.async_step_finish() + + async def async_step_finish(self, user_input=None): + if user_input: + return self.async_create_entry( + title="Mock title", data=user_input, unique_id="test" + ) + + return self.async_show_form( + step_id="finish", data_schema=vol.Schema({"enabled": bool}) + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + subentries_data=[ + core_ce.ConfigSubentryData( + data={}, + subentry_id="mock_id", + subentry_type="test", + title="Title", + unique_id="test", + ) + ], + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "finish", + "data_schema": [{"name": "enabled", "type": "boolean"}], + "description_placeholders": None, + "errors": None, + "last_step": None, + "preview": None, + } + + with mock_config_flow("test", TestFlow): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"enabled": True}, + ) + assert resp.status == HTTPStatus.OK + + entries = hass.config_entries.async_entries("test") + assert len(entries) == 1 + + data = await resp.json() + data.pop("flow_id") + assert data == { + "handler": ["test1", "test"], + "reason": "already_configured", + "type": "abort", + "description_placeholders": None, + } + + async def test_subentry_does_not_support_reconfigure( hass: HomeAssistant, client: TestClient ) -> None: diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index ea7a65f25d3..15a7ac70ac7 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -1,6 +1,7 @@ """Test entity_registry API.""" from datetime import datetime +import logging from freezegun.api import FrozenDateTimeFactory import pytest @@ -11,6 +12,7 @@ from homeassistant.const import ATTR_ICON, EntityCategory 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_component import EntityComponent from homeassistant.helpers.entity_registry import ( RegistryEntryDisabler, RegistryEntryHider, @@ -1288,3 +1290,170 @@ async def test_remove_non_existing_entity( msg = await client.receive_json() assert not msg["success"] + + +_LOGGER = logging.getLogger(__name__) +DOMAIN = "test_domain" + + +async def test_get_automatic_entity_ids( + hass: HomeAssistant, client: MockHAClientWebSocket +) -> None: + """Test get_automatic_entity_ids.""" + mock_registry( + hass, + { + "test_domain.test_1": RegistryEntryWithDefaults( + entity_id="test_domain.test_1", + unique_id="uniq1", + platform="test_domain", + ), + "test_domain.test_2": RegistryEntryWithDefaults( + entity_id="test_domain.test_2", + unique_id="uniq2", + platform="test_domain", + suggested_object_id="collision", + ), + "test_domain.test_3": RegistryEntryWithDefaults( + entity_id="test_domain.test_3", + name="Name by User 3", + unique_id="uniq3", + platform="test_domain", + suggested_object_id="suggested_3", + ), + "test_domain.test_4": RegistryEntryWithDefaults( + entity_id="test_domain.test_4", + name="Name by User 4", + unique_id="uniq4", + platform="test_domain", + ), + "test_domain.test_5": RegistryEntryWithDefaults( + entity_id="test_domain.test_5", + unique_id="uniq5", + platform="test_domain", + ), + "test_domain.test_6": RegistryEntryWithDefaults( + entity_id="test_domain.test_6", + name="Test 6", + unique_id="uniq6", + platform="test_domain", + ), + "test_domain.test_7": RegistryEntryWithDefaults( + entity_id="test_domain.test_7", + unique_id="uniq7", + platform="test_domain", + suggested_object_id="test_7", + ), + "test_domain.not_unique": RegistryEntryWithDefaults( + entity_id="test_domain.not_unique", + unique_id="not_unique_1", + platform="test_domain", + suggested_object_id="not_unique", + ), + "test_domain.not_unique_2": RegistryEntryWithDefaults( + entity_id="test_domain.not_unique_2", + name="Not Unique", + unique_id="not_unique_2", + platform="test_domain", + ), + "test_domain.not_unique_3": RegistryEntryWithDefaults( + entity_id="test_domain.not_unique_3", + unique_id="not_unique_3", + platform="test_domain", + suggested_object_id="not_unique", + ), + "test_domain.also_not_unique_changed_1": RegistryEntryWithDefaults( + entity_id="test_domain.also_not_unique_changed_1", + unique_id="also_not_unique_1", + platform="test_domain", + ), + "test_domain.also_not_unique_changed_2": RegistryEntryWithDefaults( + entity_id="test_domain.also_not_unique_changed_2", + unique_id="also_not_unique_2", + platform="test_domain", + ), + "test_domain.collision": RegistryEntryWithDefaults( + entity_id="test_domain.collision", + unique_id="uniq_collision", + platform="test_platform", + ), + }, + ) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) + entity2 = MockEntity(unique_id="uniq2", name="Entity Name 2") + entity3 = MockEntity(unique_id="uniq3", name="Entity Name 3") + entity4 = MockEntity(unique_id="uniq4", name="Entity Name 4") + entity5 = MockEntity(unique_id="uniq5", name="Entity Name 5") + entity6 = MockEntity(unique_id="uniq6", name="Entity Name 6") + entity7 = MockEntity(unique_id="uniq7", name="Entity Name 7") + entity8 = MockEntity(unique_id="not_unique_1", name="Entity Name 8") + entity9 = MockEntity(unique_id="not_unique_2", name="Entity Name 9") + entity10 = MockEntity(unique_id="not_unique_3", name="Not unique") + entity11 = MockEntity(unique_id="also_not_unique_1", name="Also not unique") + entity12 = MockEntity(unique_id="also_not_unique_2", name="Also not unique") + await component.async_add_entities( + [ + entity2, + entity3, + entity4, + entity5, + entity6, + entity7, + entity8, + entity9, + entity10, + entity11, + entity12, + ] + ) + + await client.send_json_auto_id( + { + "type": "config/entity_registry/get_automatic_entity_ids", + "entity_ids": [ + "test_domain.test_1", + "test_domain.test_2", + "test_domain.test_3", + "test_domain.test_4", + "test_domain.test_5", + "test_domain.test_6", + "test_domain.test_7", + "test_domain.not_unique", + "test_domain.not_unique_2", + "test_domain.not_unique_3", + "test_domain.also_not_unique_changed_1", + "test_domain.also_not_unique_changed_2", + "test_domain.unknown", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == { + # No entity object for test_domain.test_1 + "test_domain.test_1": None, + # The suggested_object_id is taken, fall back to suggested_object_id + _2 + "test_domain.test_2": "test_domain.collision_2", + # name set by user has higher priority than suggested_object_id or entity + "test_domain.test_3": "test_domain.name_by_user_3", + # name set by user has higher priority than entity properties + "test_domain.test_4": "test_domain.name_by_user_4", + # No suggested_object_id or name, fall back to entity properties + "test_domain.test_5": "test_domain.entity_name_5", + # automatic entity id matches current entity id + "test_domain.test_6": "test_domain.test_6", + "test_domain.test_7": "test_domain.test_7", + # colliding entity ids keep current entity id + "test_domain.not_unique": "test_domain.not_unique", + "test_domain.not_unique_2": "test_domain.not_unique_2", + "test_domain.not_unique_3": "test_domain.not_unique_3", + # Don't reuse entity id + "test_domain.also_not_unique_changed_1": "test_domain.also_not_unique", + "test_domain.also_not_unique_changed_2": "test_domain.also_not_unique_2", + # no test_domain.unknown in registry + "test_domain.unknown": None, + } diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 3d843d4e32a..a853faa7a3d 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -29,18 +29,21 @@ dict({ 'id': 'conversation.home_assistant', 'name': 'Home Assistant', + 'supports_streaming': False, }) # --- # name: test_get_agent_info.1 dict({ 'id': 'mock-entry', 'name': 'Mock Title', + 'supports_streaming': False, }) # --- # name: test_get_agent_info.2 dict({ 'id': 'conversation.home_assistant', 'name': 'Home Assistant', + 'supports_streaming': False, }) # --- # name: test_turn_on_intent[None-turn kitchen on-None] diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index dca4653b480..f075f267111 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, patch from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion import yaml from homeassistant.components import conversation, cover, media_player, weather diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 9ac5c7d16a4..c3de5f1127c 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -220,6 +220,13 @@ async def test_get_agent_info( agent_info = conversation.async_get_agent_info(hass) assert agent_info == snapshot + default_agent = conversation.async_get_agent(hass) + default_agent._attr_supports_streaming = True + assert ( + conversation.async_get_agent_info(hass, "homeassistant").supports_streaming + is True + ) + @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) async def test_prepare_agent( diff --git a/tests/components/cookidoo/snapshots/test_button.ambr b/tests/components/cookidoo/snapshots/test_button.ambr index f316b0cfc82..43244132ae2 100644 --- a/tests/components/cookidoo/snapshots/test_button.ambr +++ b/tests/components/cookidoo/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Clear shopping list and additional purchases', 'platform': 'cookidoo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'todo_clear', 'unique_id': 'sub_uuid_todo_clear', diff --git a/tests/components/cookidoo/snapshots/test_sensor.ambr b/tests/components/cookidoo/snapshots/test_sensor.ambr index ca861241971..6b311cfea86 100644 --- a/tests/components/cookidoo/snapshots/test_sensor.ambr +++ b/tests/components/cookidoo/snapshots/test_sensor.ambr @@ -33,6 +33,7 @@ 'original_name': 'Subscription', 'platform': 'cookidoo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'sub_uuid_subscription', @@ -86,6 +87,7 @@ 'original_name': 'Subscription expiration date', 'platform': 'cookidoo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'sub_uuid_expires', diff --git a/tests/components/cookidoo/snapshots/test_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr index 5b2c7552548..620d3c55db7 100644 --- a/tests/components/cookidoo/snapshots/test_todo.ambr +++ b/tests/components/cookidoo/snapshots/test_todo.ambr @@ -27,6 +27,7 @@ 'original_name': 'Additional purchases', 'platform': 'cookidoo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'additional_item_list', 'unique_id': 'sub_uuid_additional_items', @@ -75,6 +76,7 @@ 'original_name': 'Shopping list', 'platform': 'cookidoo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'ingredient_list', 'unique_id': 'sub_uuid_ingredients', diff --git a/tests/components/cookidoo/test_button.py b/tests/components/cookidoo/test_button.py index 3e832ec9fe6..f96cbf4665d 100644 --- a/tests/components/cookidoo/test_button.py +++ b/tests/components/cookidoo/test_button.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from cookidoo_api import CookidooRequestException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/cookidoo/test_diagnostics.py b/tests/components/cookidoo/test_diagnostics.py index c253e1f6e09..1bd172f846f 100644 --- a/tests/components/cookidoo/test_diagnostics.py +++ b/tests/components/cookidoo/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/cpuspeed/test_diagnostics.py b/tests/components/cpuspeed/test_diagnostics.py index a596c7d62d9..e84235af3b0 100644 --- a/tests/components/cpuspeed/test_diagnostics.py +++ b/tests/components/cpuspeed/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/cups/__init__.py b/tests/components/cups/__init__.py new file mode 100644 index 00000000000..c96e2d7c7dc --- /dev/null +++ b/tests/components/cups/__init__.py @@ -0,0 +1 @@ +"""CUPS tests.""" diff --git a/tests/components/cups/test_sensor.py b/tests/components/cups/test_sensor.py new file mode 100644 index 00000000000..60e7ce5fd44 --- /dev/null +++ b/tests/components/cups/test_sensor.py @@ -0,0 +1,40 @@ +"""Tests for the CUPS sensor platform.""" + +from unittest.mock import patch + +from homeassistant.components.cups import CONF_PRINTERS, DOMAIN as CUPS_DOMAIN +from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + with patch( + "homeassistant.components.cups.sensor.CupsData", autospec=True + ) as cups_data: + cups_data.available = True + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: [ + { + CONF_PLATFORM: CUPS_DOMAIN, + CONF_PRINTERS: [ + "printer1", + ], + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{CUPS_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/deako/snapshots/test_light.ambr b/tests/components/deako/snapshots/test_light.ambr index f5ef5fd19e8..bed3bc366e8 100644 --- a/tests/components/deako/snapshots/test_light.ambr +++ b/tests/components/deako/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': None, 'platform': 'deako', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'uuid', @@ -88,6 +89,7 @@ 'original_name': None, 'platform': 'deako', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'uuid', @@ -144,6 +146,7 @@ 'original_name': None, 'platform': 'deako', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'some_device', diff --git a/tests/components/deako/test_init.py b/tests/components/deako/test_init.py index c2291330feb..33428f4f81c 100644 --- a/tests/components/deako/test_init.py +++ b/tests/components/deako/test_init.py @@ -21,6 +21,7 @@ async def test_deako_async_setup_entry( "id1": {}, "id2": {}, } + pydeako_deako_mock.return_value.get_name.return_value = "some device" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/deconz/snapshots/test_alarm_control_panel.ambr b/tests/components/deconz/snapshots/test_alarm_control_panel.ambr index e1a6126498c..95c5cada755 100644 --- a/tests/components/deconz/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/deconz/snapshots/test_alarm_control_panel.ambr @@ -27,6 +27,7 @@ 'original_name': 'Keypad', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00', diff --git a/tests/components/deconz/snapshots/test_binary_sensor.ambr b/tests/components/deconz/snapshots/test_binary_sensor.ambr index 6b348d3ed0a..6fb1140ec6f 100644 --- a/tests/components/deconz/snapshots/test_binary_sensor.ambr +++ b/tests/components/deconz/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Alarm 10', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:b5:d1:80-01-0500-alarm', @@ -77,6 +78,7 @@ 'original_name': 'Cave CO', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:a5:21:24-01-0101-carbon_monoxide', @@ -126,6 +128,7 @@ 'original_name': 'Cave CO Low Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:a5:21:24-01-0101-low_battery', @@ -174,6 +177,7 @@ 'original_name': 'Cave CO Tampered', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:a5:21:24-01-0101-tampered', @@ -222,6 +226,7 @@ 'original_name': 'Presence sensor', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-presence', @@ -273,6 +278,7 @@ 'original_name': 'Presence sensor Low Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-low_battery', @@ -321,6 +327,7 @@ 'original_name': 'Presence sensor Tampered', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-tampered', @@ -369,6 +376,7 @@ 'original_name': 'sensor_kitchen_smoke', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:01:d9:3e:7c-01-0500-fire', @@ -418,6 +426,7 @@ 'original_name': 'sensor_kitchen_smoke Test Mode', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:01:d9:3e:7c-01-0500-in_test_mode', @@ -466,6 +475,7 @@ 'original_name': 'sensor_kitchen_smoke', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:01:d9:3e:7c-01-0500-fire', @@ -515,6 +525,7 @@ 'original_name': 'sensor_kitchen_smoke Test Mode', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:01:d9:3e:7c-01-0500-in_test_mode', @@ -563,6 +574,7 @@ 'original_name': 'Kitchen Switch', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'kitchen-switch-flag', @@ -611,6 +623,7 @@ 'original_name': 'Back Door', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:2b:96:b4-01-0006-open', @@ -661,6 +674,7 @@ 'original_name': 'Motion sensor 4', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:17:88:01:03:28:8c:9b-02-0406-presence', @@ -711,6 +725,7 @@ 'original_name': 'water2', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:2f:07:db-01-0500-water', @@ -761,6 +776,7 @@ 'original_name': 'water2 Low Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:2f:07:db-01-0500-low_battery', @@ -809,6 +825,7 @@ 'original_name': 'water2 Tampered', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:2f:07:db-01-0500-tampered', @@ -857,6 +874,7 @@ 'original_name': 'Vibration 1', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:a5:21:24-01-0101-vibration', @@ -914,6 +932,7 @@ 'original_name': 'Presence sensor', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-presence', @@ -965,6 +984,7 @@ 'original_name': 'Presence sensor Low Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-low_battery', @@ -1013,6 +1033,7 @@ 'original_name': 'Presence sensor Tampered', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-tampered', diff --git a/tests/components/deconz/snapshots/test_button.ambr b/tests/components/deconz/snapshots/test_button.ambr index b7ad00cdacd..237b0e1e50f 100644 --- a/tests/components/deconz/snapshots/test_button.ambr +++ b/tests/components/deconz/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Scene Store Current Scene', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01234E56789A/groups/1/scenes/1-store', @@ -75,6 +76,7 @@ 'original_name': 'Aqara FP1 Reset Presence', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-reset_presence', diff --git a/tests/components/deconz/snapshots/test_climate.ambr b/tests/components/deconz/snapshots/test_climate.ambr index f8d572ab2ca..cdae69abbcb 100644 --- a/tests/components/deconz/snapshots/test_climate.ambr +++ b/tests/components/deconz/snapshots/test_climate.ambr @@ -45,6 +45,7 @@ 'original_name': 'Zen-01', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:24:46:00:00:11:6f:56-01-0201', @@ -133,6 +134,7 @@ 'original_name': 'Zen-01', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:24:46:00:00:11:6f:56-01-0201', @@ -230,6 +232,7 @@ 'original_name': 'Zen-01', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:24:46:00:00:11:6f:56-01-0201', @@ -318,6 +321,7 @@ 'original_name': 'Thermostat', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00', @@ -385,6 +389,7 @@ 'original_name': 'CLIP thermostat', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:02-00', @@ -451,6 +456,7 @@ 'original_name': 'Thermostat', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00', @@ -518,6 +524,7 @@ 'original_name': 'thermostat', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '14:b4:57:ff:fe:d5:4e:77-01-0201', diff --git a/tests/components/deconz/snapshots/test_cover.ambr b/tests/components/deconz/snapshots/test_cover.ambr index 41ff4e950a8..15e51b8443f 100644 --- a/tests/components/deconz/snapshots/test_cover.ambr +++ b/tests/components/deconz/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': 'Window covering device', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-00', @@ -77,6 +78,7 @@ 'original_name': 'Vent', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:22:a3:00:00:00:00:00-01', @@ -128,6 +130,7 @@ 'original_name': 'Covering device', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:24:46:00:00:12:34:56-01', diff --git a/tests/components/deconz/snapshots/test_fan.ambr b/tests/components/deconz/snapshots/test_fan.ambr index 6a260c39673..d8d6f7703f2 100644 --- a/tests/components/deconz/snapshots/test_fan.ambr +++ b/tests/components/deconz/snapshots/test_fan.ambr @@ -29,6 +29,7 @@ 'original_name': 'Ceiling fan', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:22:a3:00:00:27:8b:81-01', diff --git a/tests/components/deconz/snapshots/test_light.ambr b/tests/components/deconz/snapshots/test_light.ambr index 212ccd84d0c..39ce5e46236 100644 --- a/tests/components/deconz/snapshots/test_light.ambr +++ b/tests/components/deconz/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': 'Dimmable light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:02-00', @@ -97,6 +98,7 @@ 'original_name': None, 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01234E56789A-/groups/0', @@ -183,6 +185,7 @@ 'original_name': 'RGB light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00', @@ -262,6 +265,7 @@ 'original_name': 'Tunable white light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-00', @@ -339,6 +343,7 @@ 'original_name': 'Dimmable light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:02-00', @@ -405,6 +410,7 @@ 'original_name': None, 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01234E56789A-/groups/0', @@ -491,6 +497,7 @@ 'original_name': 'RGB light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00', @@ -570,6 +577,7 @@ 'original_name': 'Tunable white light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-00', @@ -647,6 +655,7 @@ 'original_name': 'Dimmable light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:02-00', @@ -713,6 +722,7 @@ 'original_name': None, 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01234E56789A-/groups/0', @@ -799,6 +809,7 @@ 'original_name': 'RGB light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00', @@ -878,6 +889,7 @@ 'original_name': 'Tunable white light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-00', @@ -964,6 +976,7 @@ 'original_name': 'Hue Go', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:17:88:01:01:23:45:67-00', @@ -1056,6 +1069,7 @@ 'original_name': 'Hue Ensis', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:17:88:01:01:23:45:67-01', @@ -1157,6 +1171,7 @@ 'original_name': 'LIDL xmas light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '58:8e:81:ff:fe:db:7b:be-01', @@ -1251,6 +1266,7 @@ 'original_name': 'Hue White Ambiance', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:17:88:01:01:23:45:67-02', @@ -1328,6 +1344,7 @@ 'original_name': 'Hue Filament', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:17:88:01:01:23:45:67-03', @@ -1386,6 +1403,7 @@ 'original_name': 'Simple Light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:01:23:45:67-01', @@ -1457,6 +1475,7 @@ 'original_name': 'Gradient light', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:17:88:01:0b:0c:0d:0e-0f', diff --git a/tests/components/deconz/snapshots/test_number.ambr b/tests/components/deconz/snapshots/test_number.ambr index 173d5e87043..d264740e4c2 100644 --- a/tests/components/deconz/snapshots/test_number.ambr +++ b/tests/components/deconz/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Presence sensor Delay', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-delay', @@ -88,6 +89,7 @@ 'original_name': 'Presence sensor Duration', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-duration', diff --git a/tests/components/deconz/snapshots/test_scene.ambr b/tests/components/deconz/snapshots/test_scene.ambr index 21456afaea1..4c04c6661d5 100644 --- a/tests/components/deconz/snapshots/test_scene.ambr +++ b/tests/components/deconz/snapshots/test_scene.ambr @@ -27,6 +27,7 @@ 'original_name': 'Scene', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01234E56789A/groups/1/scenes/1', diff --git a/tests/components/deconz/snapshots/test_select.ambr b/tests/components/deconz/snapshots/test_select.ambr index 7fa2aaf11cb..5b8dc9509a7 100644 --- a/tests/components/deconz/snapshots/test_select.ambr +++ b/tests/components/deconz/snapshots/test_select.ambr @@ -32,6 +32,7 @@ 'original_name': 'Aqara FP1 Device Mode', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-device_mode', @@ -89,6 +90,7 @@ 'original_name': 'Aqara FP1 Sensitivity', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-sensitivity', @@ -147,6 +149,7 @@ 'original_name': 'Aqara FP1 Trigger Distance', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-trigger_distance', @@ -204,6 +207,7 @@ 'original_name': 'Aqara FP1 Device Mode', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-device_mode', @@ -261,6 +265,7 @@ 'original_name': 'Aqara FP1 Sensitivity', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-sensitivity', @@ -319,6 +324,7 @@ 'original_name': 'Aqara FP1 Trigger Distance', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-trigger_distance', @@ -376,6 +382,7 @@ 'original_name': 'Aqara FP1 Device Mode', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-device_mode', @@ -433,6 +440,7 @@ 'original_name': 'Aqara FP1 Sensitivity', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-sensitivity', @@ -491,6 +499,7 @@ 'original_name': 'Aqara FP1 Trigger Distance', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-0406-trigger_distance', @@ -553,6 +562,7 @@ 'original_name': 'IKEA Starkvind Fan Mode', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '0c:43:14:ff:fe:6c:20:12-01-fc7d-fan_mode', diff --git a/tests/components/deconz/snapshots/test_sensor.ambr b/tests/components/deconz/snapshots/test_sensor.ambr index be397f0e22a..04f93738b18 100644 --- a/tests/components/deconz/snapshots/test_sensor.ambr +++ b/tests/components/deconz/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'CLIP Flur', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/sensors/3-status', @@ -77,6 +78,7 @@ 'original_name': 'CLIP light level sensor', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-00-light_level', @@ -129,6 +131,7 @@ 'original_name': 'Light level sensor', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-light_level', @@ -178,12 +181,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Light level sensor Temperature', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:00-00-internal_temperature', @@ -234,6 +241,7 @@ 'original_name': 'BOSCH Air quality sensor', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:12:4b:00:14:4d:00:07-02-fdef-air_quality', @@ -283,6 +291,7 @@ 'original_name': 'BOSCH Air quality sensor PPB', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:12:4b:00:14:4d:00:07-02-fdef-air_quality_ppb', @@ -332,6 +341,7 @@ 'original_name': 'BOSCH Air quality sensor', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:12:4b:00:14:4d:00:07-02-fdef-air_quality', @@ -381,6 +391,7 @@ 'original_name': 'BOSCH Air quality sensor PPB', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:12:4b:00:14:4d:00:07-02-fdef-air_quality_ppb', @@ -430,6 +441,7 @@ 'original_name': 'FSM_STATE Motion stair', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'fsm-state-1520195376277-status', @@ -483,6 +495,7 @@ 'original_name': 'Mi temperature 1', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:45:dc:53-01-0405-humidity', @@ -536,6 +549,7 @@ 'original_name': 'Mi temperature 1 Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:45:dc:53-01-0405-battery', @@ -592,6 +606,7 @@ 'original_name': 'Soil Sensor', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a4:c1:38:fe:86:8f:07:a3-01-0408-moisture', @@ -644,6 +659,7 @@ 'original_name': 'Soil Sensor Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a4:c1:38:fe:86:8f:07:a3-01-0408-battery', @@ -697,6 +713,7 @@ 'original_name': 'Motion sensor 4', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:17:88:01:03:28:8c:9b-02-0400-light_level', @@ -752,6 +769,7 @@ 'original_name': 'Motion sensor 4 Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:17:88:01:03:28:8c:9b-02-0400-battery', @@ -807,6 +825,7 @@ 'original_name': 'STARKVIND AirPurifier PM25', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-042a-particulate_matter_pm2_5', @@ -853,12 +872,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power 16', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:0d:6f:00:0b:7a:64:29-01-0b04-power', @@ -908,12 +931,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mi temperature 1', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:45:dc:53-01-0403-pressure', @@ -967,6 +994,7 @@ 'original_name': 'Mi temperature 1 Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:45:dc:53-01-0403-battery', @@ -1023,6 +1051,7 @@ 'original_name': 'Mi temperature 1', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:45:dc:53-01-0402-temperature', @@ -1076,6 +1105,7 @@ 'original_name': 'Mi temperature 1 Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:45:dc:53-01-0402-battery', @@ -1127,6 +1157,7 @@ 'original_name': 'eTRV Séjour', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'cc:cc:cc:ff:fe:38:4d:b3-01-000a-last_set', @@ -1177,6 +1208,7 @@ 'original_name': 'eTRV Séjour Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'cc:cc:cc:ff:fe:38:4d:b3-01-000a-battery', @@ -1230,6 +1262,7 @@ 'original_name': 'Alarm 10 Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:b5:d1:80-01-0500-battery', @@ -1278,12 +1311,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Alarm 10 Temperature', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:15:8d:00:02:b5:d1:80-01-0500-internal_temperature', @@ -1336,6 +1373,7 @@ 'original_name': 'AirQuality 1 CH2O', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_formaldehyde', @@ -1388,6 +1426,7 @@ 'original_name': 'AirQuality 1 CO2', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_co2', @@ -1440,6 +1479,7 @@ 'original_name': 'AirQuality 1 PM25', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_pm2_5', @@ -1492,6 +1532,7 @@ 'original_name': 'AirQuality 1 PPB', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_ppb', @@ -1543,6 +1584,7 @@ 'original_name': 'Dimmer switch 3 Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:17:88:01:02:0e:32:a3-02-fc00-battery', @@ -1601,6 +1643,7 @@ 'original_name': 'IKEA Starkvind Filter time', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '0c:43:14:ff:fe:6c:20:12-01-fc7d-air_purifier_filter_run_time', @@ -1652,6 +1695,7 @@ 'original_name': 'AirQuality 1 CH2O', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_formaldehyde', @@ -1704,6 +1748,7 @@ 'original_name': 'AirQuality 1 CO2', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_co2', @@ -1756,6 +1801,7 @@ 'original_name': 'AirQuality 1 PM25', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_pm2_5', @@ -1808,6 +1854,7 @@ 'original_name': 'AirQuality 1 PPB', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_ppb', @@ -1859,6 +1906,7 @@ 'original_name': 'AirQuality 1 CH2O', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_formaldehyde', @@ -1911,6 +1959,7 @@ 'original_name': 'AirQuality 1 CO2', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_co2', @@ -1963,6 +2012,7 @@ 'original_name': 'AirQuality 1 PM25', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_pm2_5', @@ -2015,6 +2065,7 @@ 'original_name': 'AirQuality 1 PPB', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_ppb', @@ -2066,6 +2117,7 @@ 'original_name': 'FYRTUR block-out roller blind Battery', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:0d:6f:ff:fe:01:23:45-01-0001-battery', @@ -2119,6 +2171,7 @@ 'original_name': 'CarbonDioxide 35', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-040d-carbon_dioxide', @@ -2165,12 +2218,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Consumption 15', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:0d:6f:00:0b:7a:64:29-01-0702-consumption', @@ -2223,6 +2280,7 @@ 'original_name': 'Daylight', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01:23:4E:FF:FF:56:78:9A-01-daylight_status', @@ -2275,6 +2333,7 @@ 'original_name': 'Formaldehyde 34', 'platform': 'deconz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-042b-formaldehyde', diff --git a/tests/components/deconz/test_alarm_control_panel.py b/tests/components/deconz/test_alarm_control_panel.py index dbe75584df7..8e0b696c274 100644 --- a/tests/components/deconz/test_alarm_control_panel.py +++ b/tests/components/deconz/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from unittest.mock import patch from pydeconz.models.sensor.ancillary_control import AncillaryControlPanel import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index 59d31afb9fc..288be082f43 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.deconz.const import ( CONF_ALLOW_CLIP_SENSOR, diff --git a/tests/components/deconz/test_button.py b/tests/components/deconz/test_button.py index c649dba5b00..4451d68c186 100644 --- a/tests/components/deconz/test_button.py +++ b/tests/components/deconz/test_button.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index e1000f0b4d6..723ff12ad37 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -4,7 +4,7 @@ from collections.abc import Callable from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, diff --git a/tests/components/deconz/test_cover.py b/tests/components/deconz/test_cover.py index 47f8083798e..99f78dd1a92 100644 --- a/tests/components/deconz/test_cover.py +++ b/tests/components/deconz/test_cover.py @@ -4,7 +4,7 @@ from collections.abc import Callable from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, diff --git a/tests/components/deconz/test_diagnostics.py b/tests/components/deconz/test_diagnostics.py index 2abc6d83995..640e8947c17 100644 --- a/tests/components/deconz/test_diagnostics.py +++ b/tests/components/deconz/test_diagnostics.py @@ -1,7 +1,7 @@ """Test deCONZ diagnostics.""" from pydeconz.websocket import State -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/deconz/test_fan.py b/tests/components/deconz/test_fan.py index 21809a138c6..a544f46e39d 100644 --- a/tests/components/deconz/test_fan.py +++ b/tests/components/deconz/test_fan.py @@ -4,7 +4,7 @@ from collections.abc import Callable from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, diff --git a/tests/components/deconz/test_hub.py b/tests/components/deconz/test_hub.py index 1b000828b85..f674a6ef6df 100644 --- a/tests/components/deconz/test_hub.py +++ b/tests/components/deconz/test_hub.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pydeconz.websocket import State import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 9ac15d4867b..6aacdf7011b 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.deconz.const import CONF_ALLOW_DECONZ_GROUPS from homeassistant.components.light import ( diff --git a/tests/components/deconz/test_number.py b/tests/components/deconz/test_number.py index 962c2c0a89b..dd2f26eec4b 100644 --- a/tests/components/deconz/test_number.py +++ b/tests/components/deconz/test_number.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/deconz/test_scene.py b/tests/components/deconz/test_scene.py index c1240b6881c..d03cbec28e0 100644 --- a/tests/components/deconz/test_scene.py +++ b/tests/components/deconz/test_scene.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/deconz/test_select.py b/tests/components/deconz/test_select.py index c677853841c..5d79cb8cd50 100644 --- a/tests/components/deconz/test_select.py +++ b/tests/components/deconz/test_select.py @@ -10,7 +10,7 @@ from pydeconz.models.sensor.presence import ( PresenceConfigTriggerDistance, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 958cb3b793a..521ff3c7efb 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.deconz.const import CONF_ALLOW_CLIP_SENSOR from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN diff --git a/tests/components/decora/__init__.py b/tests/components/decora/__init__.py new file mode 100644 index 00000000000..399b353aa0c --- /dev/null +++ b/tests/components/decora/__init__.py @@ -0,0 +1 @@ +"""Decora component tests.""" diff --git a/tests/components/decora/test_light.py b/tests/components/decora/test_light.py new file mode 100644 index 00000000000..6315d6c3986 --- /dev/null +++ b/tests/components/decora/test_light.py @@ -0,0 +1,34 @@ +"""Decora component tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.decora import DOMAIN as DECORA_DOMAIN +from homeassistant.components.light import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", {"bluepy": Mock(), "bluepy.btle": Mock(), "decora": Mock()}) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + PLATFORM_DOMAIN, + { + PLATFORM_DOMAIN: [ + { + CONF_PLATFORM: DECORA_DOMAIN, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DECORA_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index 32802080e39..d237703eb2e 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -1,23 +1,103 @@ """Test the Derivative integration.""" +from unittest.mock import patch + import pytest +from homeassistant.components import derivative +from homeassistant.components.derivative.config_flow import ConfigFlowHandler from homeassistant.components.derivative.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry -@pytest.mark.parametrize("platform", ["sensor"]) +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def derivative_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a derivative config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": sensor_entity_entry.entity_id, + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_setup_and_remove_config_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, - platform: str, ) -> None: """Test setting up and removing a config entry.""" input_sensor_entity_id = "sensor.input" - derivative_entity_id = f"{platform}.my_derivative" + derivative_entity_id = "sensor.my_derivative" # Setup the config entry config_entry = MockConfigEntry( @@ -147,3 +227,194 @@ async def test_device_cleaning( derivative_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the derivative config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the derivative config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the derivative config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert derivative_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the derivative config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert derivative_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the derivative config entry is updated with the new entity ID + assert derivative_config_entry.options["source"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] diff --git a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr index 659420c1590..cb0c03e4b4e 100644 --- a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr @@ -41,6 +41,7 @@ 'original_name': 'Door', 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Test', @@ -89,6 +90,7 @@ 'original_name': 'Overload', 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'overload', 'unique_id': 'Overload', @@ -136,6 +138,7 @@ 'original_name': 'Button 1', 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': 'Test_1', diff --git a/tests/components/devolo_home_control/snapshots/test_climate.ambr b/tests/components/devolo_home_control/snapshots/test_climate.ambr index 96ffe45c4a4..a42eece1bf8 100644 --- a/tests/components/devolo_home_control/snapshots/test_climate.ambr +++ b/tests/components/devolo_home_control/snapshots/test_climate.ambr @@ -56,6 +56,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'Test', diff --git a/tests/components/devolo_home_control/snapshots/test_cover.ambr b/tests/components/devolo_home_control/snapshots/test_cover.ambr index 44bff626923..53a2582bd3d 100644 --- a/tests/components/devolo_home_control/snapshots/test_cover.ambr +++ b/tests/components/devolo_home_control/snapshots/test_cover.ambr @@ -43,6 +43,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.Blinds', diff --git a/tests/components/devolo_home_control/snapshots/test_light.ambr b/tests/components/devolo_home_control/snapshots/test_light.ambr index 11dc768a519..f66fd4add1f 100644 --- a/tests/components/devolo_home_control/snapshots/test_light.ambr +++ b/tests/components/devolo_home_control/snapshots/test_light.ambr @@ -50,6 +50,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Dimmer:Test', @@ -107,6 +108,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Dimmer:Test', diff --git a/tests/components/devolo_home_control/snapshots/test_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_sensor.ambr index 7cca8b23e77..77f18621364 100644 --- a/tests/components/devolo_home_control/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_sensor.ambr @@ -45,6 +45,7 @@ 'original_name': 'Battery', 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.BatterySensor:Test', @@ -96,6 +97,7 @@ 'original_name': 'Brightness', 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brightness', 'unique_id': 'devolo.MultiLevelSensor:Test', @@ -142,12 +144,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Meter:Test_current', @@ -194,12 +200,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy', 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Meter:Test_total', @@ -246,12 +256,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.MultiLevelSensor:Test', diff --git a/tests/components/devolo_home_control/snapshots/test_siren.ambr b/tests/components/devolo_home_control/snapshots/test_siren.ambr index 41b68574065..463af865ad8 100644 --- a/tests/components/devolo_home_control/snapshots/test_siren.ambr +++ b/tests/components/devolo_home_control/snapshots/test_siren.ambr @@ -48,6 +48,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', @@ -103,6 +104,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', @@ -158,6 +160,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', diff --git a/tests/components/devolo_home_control/snapshots/test_switch.ambr b/tests/components/devolo_home_control/snapshots/test_switch.ambr index d3097716092..1047f0580c5 100644 --- a/tests/components/devolo_home_control/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_control/snapshots/test_switch.ambr @@ -40,6 +40,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.BinarySwitch:Test', diff --git a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr index a33fdf084dd..5099c9881e7 100644 --- a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr @@ -41,6 +41,7 @@ 'original_name': 'Connected to router', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connected_to_router', 'unique_id': '1234567890_connected_to_router', diff --git a/tests/components/devolo_home_network/snapshots/test_button.ambr b/tests/components/devolo_home_network/snapshots/test_button.ambr index 31d8ebf31a0..d7c1ae06a6b 100644 --- a/tests/components/devolo_home_network/snapshots/test_button.ambr +++ b/tests/components/devolo_home_network/snapshots/test_button.ambr @@ -41,6 +41,7 @@ 'original_name': 'Identify device with a blinking LED', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'identify', 'unique_id': '1234567890_identify', @@ -89,6 +90,7 @@ 'original_name': 'Restart device', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'restart', 'unique_id': '1234567890_restart', @@ -136,6 +138,7 @@ 'original_name': 'Start PLC pairing', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pairing', 'unique_id': '1234567890_pairing', @@ -183,6 +186,7 @@ 'original_name': 'Start WPS', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_wps', 'unique_id': '1234567890_start_wps', diff --git a/tests/components/devolo_home_network/snapshots/test_image.ambr b/tests/components/devolo_home_network/snapshots/test_image.ambr index 3772672d8cb..5817b502eff 100644 --- a/tests/components/devolo_home_network/snapshots/test_image.ambr +++ b/tests/components/devolo_home_network/snapshots/test_image.ambr @@ -27,6 +27,7 @@ 'original_name': 'Guest Wi-Fi credentials as QR code', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'image_guest_wifi', 'unique_id': '1234567890_image_guest_wifi', diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 9e2d8879ac9..d22916552a5 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -40,6 +40,7 @@ 'original_name': 'Connected PLC devices', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connected_plc_devices', 'unique_id': '1234567890_connected_plc_devices', @@ -90,6 +91,7 @@ 'original_name': 'Connected Wi-Fi clients', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connected_wifi_clients', 'unique_id': '1234567890_connected_wifi_clients', @@ -138,6 +140,7 @@ 'original_name': 'Last restart of the device', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_restart', 'unique_id': '1234567890_last_restart', @@ -185,6 +188,7 @@ 'original_name': 'Neighboring Wi-Fi networks', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'neighboring_wifi_networks', 'unique_id': '1234567890_neighboring_wifi_networks', @@ -237,6 +241,7 @@ 'original_name': 'PLC downlink PHY rate (test2)', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plc_rx_rate', 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', @@ -289,6 +294,7 @@ 'original_name': 'PLC downlink PHY rate (test2)', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plc_rx_rate', 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', diff --git a/tests/components/devolo_home_network/snapshots/test_switch.ambr b/tests/components/devolo_home_network/snapshots/test_switch.ambr index 6499bb9a17b..85b36b425b4 100644 --- a/tests/components/devolo_home_network/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_network/snapshots/test_switch.ambr @@ -40,6 +40,7 @@ 'original_name': 'Enable guest Wi-Fi', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'switch_guest_wifi', 'unique_id': '1234567890_switch_guest_wifi', @@ -87,6 +88,7 @@ 'original_name': 'Enable LEDs', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'switch_leds', 'unique_id': '1234567890_switch_leds', diff --git a/tests/components/devolo_home_network/snapshots/test_update.ambr b/tests/components/devolo_home_network/snapshots/test_update.ambr index f4d1c0480cf..92301447ac9 100644 --- a/tests/components/devolo_home_network/snapshots/test_update.ambr +++ b/tests/components/devolo_home_network/snapshots/test_update.ambr @@ -53,6 +53,7 @@ 'original_name': 'Firmware', 'platform': 'devolo_home_network', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'regular_firmware', 'unique_id': '1234567890_regular_firmware', diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 8197ec1a1e5..e793c509b13 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -7,11 +7,9 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.devolo_home_network.const import ( - CONNECTED_TO_ROUTER, - LONG_UPDATE_INTERVAL, -) +from homeassistant.components.binary_sensor import DOMAIN as PLATFORM +from homeassistant.components.devolo_home_network.const import LONG_UPDATE_INTERVAL +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,19 +22,20 @@ from tests.common import async_fire_time_changed @pytest.mark.usefixtures("mock_device") -async def test_binary_sensor_setup(hass: HomeAssistant) -> None: +async def test_binary_sensor_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the binary sensor component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert ( - hass.states.get(f"{BINARY_SENSOR_DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}") - is None - ) - - await hass.config_entries.async_unload(entry.entry_id) + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_connected_to_router" + ).disabled @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -50,7 +49,7 @@ async def test_update_attached_to_router( """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{BINARY_SENSOR_DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}" + state_key = f"{PLATFORM}.{device_name}_connected_to_router" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -81,5 +80,3 @@ async def test_update_attached_to_router( state = hass.states.get(state_key) assert state is not None assert state.state == STATE_ON - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_button.py b/tests/components/devolo_home_network/test_button.py index b2d410b03f9..8a8028454ea 100644 --- a/tests/components/devolo_home_network/test_button.py +++ b/tests/components/devolo_home_network/test_button.py @@ -8,7 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as PLATFORM, SERVICE_PRESS from homeassistant.components.devolo_home_network.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH +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 @@ -19,22 +19,27 @@ from .mock import MockDevice @pytest.mark.usefixtures("mock_device") -async def test_button_setup(hass: HomeAssistant) -> None: +async def test_button_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the button component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert ( - hass.states.get(f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led") - is not None - ) - assert hass.states.get(f"{PLATFORM}.{device_name}_start_plc_pairing") is not None - assert hass.states.get(f"{PLATFORM}.{device_name}_restart_device") is not None - assert hass.states.get(f"{PLATFORM}.{device_name}_start_wps") is not None - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_start_plc_pairing" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_restart_device" + ).disabled + assert not entity_registry.async_get(f"{PLATFORM}.{device_name}_start_wps").disabled @pytest.mark.parametrize( @@ -107,8 +112,6 @@ async def test_button( blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) - async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: """Test setting unautherized triggers the reauth flow.""" @@ -139,5 +142,3 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index 923b7298893..589a828f29f 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -77,14 +77,30 @@ async def test_form_error(hass: HomeAssistant, exception_type, expected_error) - ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_IP_ADDRESS: IP, - }, + {CONF_IP_ADDRESS: IP, CONF_PASSWORD: ""}, ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {CONF_BASE: expected_error} + 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_IP_ADDRESS: IP, CONF_PASSWORD: ""}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + async def test_zeroconf(hass: HomeAssistant) -> None: """Test that the zeroconf form is served.""" @@ -287,5 +303,4 @@ async def test_form_reauth(hass: HomeAssistant) -> None: 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) + assert entry.data[CONF_PASSWORD] == "test-right-password" diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index ac86eb54961..2af6a1e3759 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -70,8 +70,6 @@ async def test_device_tracker( assert state is not None assert state.state == STATE_UNAVAILABLE - await hass.config_entries.async_unload(entry.entry_id) - async def test_restoring_clients( hass: HomeAssistant, diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index f13db4fce9d..54a8af3af6e 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -9,7 +9,8 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import SHORT_UPDATE_INTERVAL -from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.components.image import DOMAIN as PLATFORM +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,21 +25,20 @@ from tests.typing import ClientSessionGenerator @pytest.mark.usefixtures("mock_device") -async def test_image_setup(hass: HomeAssistant) -> None: +async def test_image_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the image component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert ( - hass.states.get( - f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" - ) - is not None - ) - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_guest_wi_fi_credentials_as_qr_code" + ).disabled @pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") @@ -53,7 +53,7 @@ async def test_guest_wifi_qr( """Test showing a QR code of the guest wifi credentials.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" + state_key = f"{PLATFORM}.{device_name}_guest_wi_fi_credentials_as_qr_code" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -95,5 +95,3 @@ async def test_guest_wifi_qr( resp = await client.get(f"/api/image_proxy/{state_key}") assert resp.status == HTTPStatus.OK assert await resp.read() != body - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index cf0207a2800..d01eb9f9e38 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -27,49 +27,41 @@ from tests.common import async_fire_time_changed @pytest.mark.usefixtures("mock_device") -async def test_sensor_setup(hass: HomeAssistant) -> None: +async def test_sensor_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the sensor component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert ( - hass.states.get(f"{PLATFORM}.{device_name}_connected_wi_fi_clients") is not None - ) - assert hass.states.get(f"{PLATFORM}.{device_name}_connected_plc_devices") is None - assert ( - hass.states.get(f"{PLATFORM}.{device_name}_neighboring_wi_fi_networks") is None - ) - assert ( - hass.states.get( - f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" - ) - is not None - ) - assert ( - hass.states.get( - f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" - ) - is not None - ) - assert ( - hass.states.get( - f"{PLATFORM}.{device_name}_plc_downlink_phyrate_{PLCNET.devices[2].user_device_name}" - ) - is None - ) - assert ( - hass.states.get( - f"{PLATFORM}.{device_name}_plc_uplink_phyrate_{PLCNET.devices[2].user_device_name}" - ) - is None - ) - assert ( - hass.states.get(f"{PLATFORM}.{device_name}_last_restart_of_the_device") is None - ) - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_connected_wi_fi_clients" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_connected_plc_devices" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_neighboring_wi_fi_networks" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[2].user_device_name}" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[2].user_device_name}" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_last_restart_of_the_device" + ).disabled @pytest.mark.parametrize( @@ -145,8 +137,6 @@ async def test_sensor( assert state is not None assert state.state == expected_state - await hass.config_entries.async_unload(entry.entry_id) - async def test_update_plc_phyrates( hass: HomeAssistant, @@ -198,8 +188,6 @@ async def test_update_plc_phyrates( assert state is not None assert state.state == str(PLCNET.data_rates[0].tx_rate) - await hass.config_entries.async_unload(entry.entry_id) - async def test_update_last_update_auth_failed( hass: HomeAssistant, mock_device: MockDevice @@ -222,5 +210,3 @@ async def test_update_last_update_auth_failed( assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 7a342780877..1ab2a1c354b 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -35,17 +35,23 @@ from tests.common import async_fire_time_changed @pytest.mark.usefixtures("mock_device") -async def test_switch_setup(hass: HomeAssistant) -> None: +async def test_switch_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the switch component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(f"{PLATFORM}.{device_name}_enable_guest_wi_fi") is not None - assert hass.states.get(f"{PLATFORM}.{device_name}_enable_leds") is not None - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_enable_guest_wi_fi" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_enable_leds" + ).disabled async def test_update_guest_wifi_status_auth_failed( @@ -70,8 +76,6 @@ async def test_update_guest_wifi_status_auth_failed( assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - await hass.config_entries.async_unload(entry.entry_id) - async def test_update_enable_guest_wifi( hass: HomeAssistant, @@ -153,8 +157,6 @@ async def test_update_enable_guest_wifi( assert state is not None assert state.state == STATE_UNAVAILABLE - await hass.config_entries.async_unload(entry.entry_id) - async def test_update_enable_leds( hass: HomeAssistant, @@ -230,8 +232,6 @@ async def test_update_enable_leds( assert state is not None assert state.state == STATE_UNAVAILABLE - await hass.config_entries.async_unload(entry.entry_id) - @pytest.mark.parametrize( ("name", "get_method", "update_interval"), @@ -325,5 +325,3 @@ async def test_auth_failed( assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 4fe7a173309..034d1bad7f6 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -11,7 +11,7 @@ from homeassistant.components.devolo_home_network.const import ( FIRMWARE_UPDATE_INTERVAL, ) from homeassistant.components.update import DOMAIN as PLATFORM, SERVICE_INSTALL -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -25,16 +25,18 @@ from tests.common import async_fire_time_changed @pytest.mark.usefixtures("mock_device") -async def test_update_setup(hass: HomeAssistant) -> None: +async def test_update_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the update component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(f"{PLATFORM}.{device_name}_firmware") is not None - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get(f"{PLATFORM}.{device_name}_firmware").disabled async def test_update_firmware( @@ -85,8 +87,6 @@ async def test_update_firmware( assert device_info is not None assert device_info.sw_version == mock_device.firmware_version - await hass.config_entries.async_unload(entry.entry_id) - async def test_device_failure_check( hass: HomeAssistant, @@ -137,8 +137,6 @@ async def test_device_failure_update( blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) - async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: """Test updating unauthorized triggers the reauth flow.""" @@ -168,5 +166,3 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index f036902faed..4f7680ee2ab 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -157,6 +157,7 @@ async def _async_get_handle_dhcp_packet( hass, DHCPData(integration_matchers, set(), address_data), ) + with patch("aiodhcpwatcher.async_start"): await dhcp_watcher.async_start() @@ -171,6 +172,53 @@ async def _async_get_handle_dhcp_packet( return cast("Callable[[Any], Awaitable[None]]", _async_handle_dhcp_packet) +async def test_dhcp_start_using_multiple_interfaces( + hass: HomeAssistant, +) -> None: + """Test start using multiple interfaces.""" + + def _generate_mock_adapters(): + return [ + { + "index": 1, + "auto": False, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.0.1", "network_prefix": 24}], + "ipv6": [], + "name": "eth0", + }, + { + "index": 2, + "auto": True, + "default": True, + "enabled": True, + "ipv4": [{"address": "192.168.1.1", "network_prefix": 24}], + "ipv6": [], + "name": "eth1", + }, + ] + + integration_matchers = dhcp.async_index_integration_matchers( + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}] + ) + dhcp_watcher = dhcp.DHCPWatcher( + hass, + DHCPData(integration_matchers, set(), {}), + ) + + with ( + patch("aiodhcpwatcher.async_start") as mock_start, + patch( + "homeassistant.components.dhcp.network.async_get_adapters", + return_value=_generate_mock_adapters(), + ), + ): + await dhcp_watcher.async_start() + + mock_start.assert_called_with(dhcp_watcher._async_process_dhcp_request, [1, 2]) + + async def test_dhcp_match_hostname_and_macaddress(hass: HomeAssistant) -> None: """Test matching based on hostname and macaddress.""" integration_matchers = dhcp.async_index_integration_matchers( diff --git a/tests/components/dhcp/test_websocket_api.py b/tests/components/dhcp/test_websocket_api.py index eb008c49ab1..0b21ef8e856 100644 --- a/tests/components/dhcp/test_websocket_api.py +++ b/tests/components/dhcp/test_websocket_api.py @@ -22,6 +22,7 @@ async def test_subscribe_discovery( async def mock_start( callback: Callable[[aiodhcpwatcher.DHCPRequest], None], + if_indexes: list[int] | None = None, ) -> None: """Mock start.""" nonlocal saved_callback diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr index 866a57c8dda..84da04a7114 100644 --- a/tests/components/discovergy/snapshots/test_sensor.ambr +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Last transmitted', 'platform': 'discovergy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_transmitted', 'unique_id': 'abc123-last_transmitted', @@ -69,6 +70,7 @@ 'original_name': 'Total consumption', 'platform': 'discovergy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_consumption', 'unique_id': 'abc123-energy', @@ -124,6 +126,7 @@ 'original_name': 'Total power', 'platform': 'discovergy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_power', 'unique_id': 'abc123-power', @@ -174,6 +177,7 @@ 'original_name': 'Last transmitted', 'platform': 'discovergy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_transmitted', 'unique_id': 'def456-last_transmitted', @@ -216,6 +220,7 @@ 'original_name': 'Total gas consumption', 'platform': 'discovergy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_gas_consumption', 'unique_id': 'def456-volume', diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py index 5c231c3d221..ca05edfe8c2 100644 --- a/tests/components/discovergy/test_diagnostics.py +++ b/tests/components/discovergy/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Discovergy diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/discovergy/test_sensor.py b/tests/components/discovergy/test_sensor.py index 814efb1ba57..20d8756ec44 100644 --- a/tests/components/discovergy/test_sensor.py +++ b/tests/components/discovergy/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/dlib_face_detect/__init__.py b/tests/components/dlib_face_detect/__init__.py new file mode 100644 index 00000000000..a732132955f --- /dev/null +++ b/tests/components/dlib_face_detect/__init__.py @@ -0,0 +1 @@ +"""The dlib_face_detect component.""" diff --git a/tests/components/dlib_face_detect/test_image_processing.py b/tests/components/dlib_face_detect/test_image_processing.py new file mode 100644 index 00000000000..e3b82a4cedf --- /dev/null +++ b/tests/components/dlib_face_detect/test_image_processing.py @@ -0,0 +1,37 @@ +"""Dlib Face Identity Image Processing Tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.dlib_face_detect import DOMAIN as DLIB_DOMAIN +from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_SOURCE +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", face_recognition=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + IMAGE_PROCESSING_DOMAIN, + { + IMAGE_PROCESSING_DOMAIN: [ + { + CONF_PLATFORM: DLIB_DOMAIN, + CONF_SOURCE: [ + {CONF_ENTITY_ID: "camera.test_camera"}, + ], + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DLIB_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/dlib_face_identify/__init__.py b/tests/components/dlib_face_identify/__init__.py new file mode 100644 index 00000000000..79b9e4ec4bc --- /dev/null +++ b/tests/components/dlib_face_identify/__init__.py @@ -0,0 +1 @@ +"""The dlib_face_identify component.""" diff --git a/tests/components/dlib_face_identify/test_image_processing.py b/tests/components/dlib_face_identify/test_image_processing.py new file mode 100644 index 00000000000..f914baeffb9 --- /dev/null +++ b/tests/components/dlib_face_identify/test_image_processing.py @@ -0,0 +1,41 @@ +"""Dlib Face Identity Image Processing Tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.dlib_face_identify import ( + CONF_FACES, + DOMAIN as DLIB_DOMAIN, +) +from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_SOURCE +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", face_recognition=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + IMAGE_PROCESSING_DOMAIN, + { + IMAGE_PROCESSING_DOMAIN: [ + { + CONF_PLATFORM: DLIB_DOMAIN, + CONF_SOURCE: [ + {CONF_ENTITY_ID: "camera.test_camera"}, + ], + CONF_FACES: {"person1": __file__}, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DLIB_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/downloader/test_init.py b/tests/components/downloader/test_init.py index 70dfd227019..e74eb376b39 100644 --- a/tests/components/downloader/test_init.py +++ b/tests/components/downloader/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from homeassistant.components.downloader import ( +from homeassistant.components.downloader.const import ( CONF_DOWNLOAD_DIR, DOMAIN, SERVICE_DOWNLOAD_FILE, diff --git a/tests/components/drop_connect/common.py b/tests/components/drop_connect/common.py index 9eb76f57dad..a695d85bab7 100644 --- a/tests/components/drop_connect/common.py +++ b/tests/components/drop_connect/common.py @@ -1,6 +1,6 @@ """Define common test values.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.drop_connect.const import ( CONF_COMMAND_TOPIC, diff --git a/tests/components/drop_connect/snapshots/test_binary_sensor.ambr b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr index 8d83482e208..0db2fe508e9 100644 --- a/tests/components/drop_connect/snapshots/test_binary_sensor.ambr +++ b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Power', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'DROP-1_C0FFEE_81_power', @@ -75,6 +76,7 @@ 'original_name': 'Sensor', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alert_sensor', 'unique_id': 'DROP-1_C0FFEE_81_alert_sensor', @@ -123,6 +125,7 @@ 'original_name': 'Leak detected', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leak', 'unique_id': 'DROP-1_C0FFEE_255_leak', @@ -171,6 +174,7 @@ 'original_name': 'Notification unread', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pending_notification', 'unique_id': 'DROP-1_C0FFEE_255_pending_notification', @@ -218,6 +222,7 @@ 'original_name': 'Leak detected', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leak', 'unique_id': 'DROP-1_C0FFEE_20_leak', @@ -266,6 +271,7 @@ 'original_name': 'Leak detected', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leak', 'unique_id': 'DROP-1_C0FFEE_78_leak', @@ -314,6 +320,7 @@ 'original_name': 'Leak detected', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leak', 'unique_id': 'DROP-1_C0FFEE_83_leak', @@ -362,6 +369,7 @@ 'original_name': 'Pump status', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pump', 'unique_id': 'DROP-1_C0FFEE_83_pump', @@ -409,6 +417,7 @@ 'original_name': 'Leak detected', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leak', 'unique_id': 'DROP-1_C0FFEE_255_leak', @@ -457,6 +466,7 @@ 'original_name': 'Reserve capacity in use', 'platform': 'drop_connect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reserve_in_use', 'unique_id': 'DROP-1_C0FFEE_0_reserve_in_use', diff --git a/tests/components/drop_connect/test_binary_sensor.py b/tests/components/drop_connect/test_binary_sensor.py index ab89e05d809..41de9d16958 100644 --- a/tests/components/drop_connect/test_binary_sensor.py +++ b/tests/components/drop_connect/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/drop_connect/test_sensor.py b/tests/components/drop_connect/test_sensor.py index c33f0aefe37..40f95c268b6 100644 --- a/tests/components/drop_connect/test_sensor.py +++ b/tests/components/drop_connect/test_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/dsmr_reader/test_diagnostics.py b/tests/components/dsmr_reader/test_diagnostics.py index 793fe1362b0..070d7d152ab 100644 --- a/tests/components/dsmr_reader/test_diagnostics.py +++ b/tests/components/dsmr_reader/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.dsmr_reader.const import DOMAIN diff --git a/tests/components/easyenergy/conftest.py b/tests/components/easyenergy/conftest.py index ffe0e36f3d2..f2ed2cf4dbc 100644 --- a/tests/components/easyenergy/conftest.py +++ b/tests/components/easyenergy/conftest.py @@ -1,7 +1,6 @@ """Fixtures for easyEnergy integration tests.""" -from collections.abc import Generator -import json +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch from easyenergy import Electricity, Gas @@ -10,7 +9,7 @@ import pytest from homeassistant.components.easyenergy.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, async_load_json_array_fixture @pytest.fixture @@ -34,17 +33,17 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_easyenergy() -> Generator[MagicMock]: +async def mock_easyenergy(hass: HomeAssistant) -> AsyncGenerator[MagicMock]: """Return a mocked easyEnergy client.""" with patch( "homeassistant.components.easyenergy.coordinator.EasyEnergy", autospec=True ) as easyenergy_mock: client = easyenergy_mock.return_value client.energy_prices.return_value = Electricity.from_dict( - json.loads(load_fixture("today_energy.json", DOMAIN)) + await async_load_json_array_fixture(hass, "today_energy.json", DOMAIN) ) client.gas_prices.return_value = Gas.from_dict( - json.loads(load_fixture("today_gas.json", DOMAIN)) + await async_load_json_array_fixture(hass, "today_gas.json", DOMAIN) ) yield client diff --git a/tests/components/easyenergy/test_diagnostics.py b/tests/components/easyenergy/test_diagnostics.py index d0eb9de3b00..8b9d850d98c 100644 --- a/tests/components/easyenergy/test_diagnostics.py +++ b/tests/components/easyenergy/test_diagnostics.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from easyenergy import EasyEnergyNoDataError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY from homeassistant.const import ATTR_ENTITY_ID diff --git a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr index 59e2f5a24b7..205ce783b8c 100644 --- a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Mop attached', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_mop_attached', 'unique_id': 'E1234567890000000001_water_mop_attached', diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr index 2c657080c12..21b7d6105f1 100644 --- a/tests/components/ecovacs/snapshots/test_button.ambr +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Reset blade lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_blade', 'unique_id': '8516fbb1-17f1-4194-0000000_reset_lifespan_blade', @@ -74,6 +75,7 @@ 'original_name': 'Reset lens brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_lens_brush', 'unique_id': '8516fbb1-17f1-4194-0000000_reset_lifespan_lens_brush', @@ -121,6 +123,7 @@ 'original_name': 'Empty dustbin', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'station_action_empty_dustbin', 'unique_id': '8516fbb1-17f1-4194-0000001_station_action_empty_dustbin', @@ -168,6 +171,7 @@ 'original_name': 'Relocate', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relocate', 'unique_id': '8516fbb1-17f1-4194-0000001_relocate', @@ -215,6 +219,7 @@ 'original_name': 'Reset filter lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_filter', 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_filter', @@ -262,6 +267,7 @@ 'original_name': 'Reset main brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_brush', 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_brush', @@ -309,6 +315,7 @@ 'original_name': 'Reset round mop lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_round_mop', 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_round_mop', @@ -356,6 +363,7 @@ 'original_name': 'Reset side brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_side_brush', 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_side_brush', @@ -403,6 +411,7 @@ 'original_name': 'Reset unit care lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_unit_care', 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_unit_care', @@ -450,6 +459,7 @@ 'original_name': 'Relocate', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relocate', 'unique_id': 'E1234567890000000001_relocate', @@ -497,6 +507,7 @@ 'original_name': 'Reset filter lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_filter', 'unique_id': 'E1234567890000000001_reset_lifespan_filter', @@ -544,6 +555,7 @@ 'original_name': 'Reset main brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_brush', 'unique_id': 'E1234567890000000001_reset_lifespan_brush', @@ -591,6 +603,7 @@ 'original_name': 'Reset side brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_lifespan_side_brush', 'unique_id': 'E1234567890000000001_reset_lifespan_side_brush', diff --git a/tests/components/ecovacs/snapshots/test_event.ambr b/tests/components/ecovacs/snapshots/test_event.ambr index d29bf8dd57a..3f72a803c6d 100644 --- a/tests/components/ecovacs/snapshots/test_event.ambr +++ b/tests/components/ecovacs/snapshots/test_event.ambr @@ -33,6 +33,7 @@ 'original_name': 'Last job', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_job', 'unique_id': 'E1234567890000000001_stats_report', diff --git a/tests/components/ecovacs/snapshots/test_lawn_mower.ambr b/tests/components/ecovacs/snapshots/test_lawn_mower.ambr index 6367872c7f7..99f4ba25bd4 100644 --- a/tests/components/ecovacs/snapshots/test_lawn_mower.ambr +++ b/tests/components/ecovacs/snapshots/test_lawn_mower.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '8516fbb1-17f1-4194-0000000_mower', @@ -61,6 +62,7 @@ 'original_name': None, 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '8516fbb1-17f1-4194-0000000_mower', diff --git a/tests/components/ecovacs/snapshots/test_number.ambr b/tests/components/ecovacs/snapshots/test_number.ambr index 952fa4556b0..b89a490c772 100644 --- a/tests/components/ecovacs/snapshots/test_number.ambr +++ b/tests/components/ecovacs/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Cut direction', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cut_direction', 'unique_id': '8516fbb1-17f1-4194-0000000_cut_direction', @@ -89,6 +90,7 @@ 'original_name': 'Volume', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '8516fbb1-17f1-4194-0000000_volume', @@ -145,6 +147,7 @@ 'original_name': 'Volume', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': 'E1234567890000000001_volume', diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr index 354afca1178..420a4a2d48e 100644 --- a/tests/components/ecovacs/snapshots/test_select.ambr +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -34,6 +34,7 @@ 'original_name': 'Water flow level', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_amount', 'unique_id': 'E1234567890000000001_water_amount', diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index 468ff0a29f8..fcd043e10fa 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Filter lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_filter', 'unique_id': 'E1234567890000000003_lifespan_filter', @@ -75,6 +76,7 @@ 'original_name': 'Main brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_main_brush', 'unique_id': 'E1234567890000000003_lifespan_main_brush', @@ -123,6 +125,7 @@ 'original_name': 'Side brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_side_brush', 'unique_id': 'E1234567890000000003_lifespan_side_brush', @@ -172,6 +175,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -181,6 +187,7 @@ 'original_name': 'Area cleaned', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stats_area', 'unique_id': '8516fbb1-17f1-4194-0000000_stats_area', @@ -199,7 +206,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0010', + 'state': '0.001', }) # --- # name: test_sensors[5xu9h3][sensor.goat_g1_battery:entity-registry] @@ -230,6 +237,7 @@ 'original_name': 'Battery', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '8516fbb1-17f1-4194-0000000_battery_level', @@ -279,6 +287,7 @@ 'original_name': 'Blade lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_blade', 'unique_id': '8516fbb1-17f1-4194-0000000_lifespan_blade', @@ -321,6 +330,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -330,6 +342,7 @@ 'original_name': 'Cleaning duration', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stats_time', 'unique_id': '8516fbb1-17f1-4194-0000000_stats_time', @@ -379,6 +392,7 @@ 'original_name': 'Error', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error', 'unique_id': '8516fbb1-17f1-4194-0000000_error', @@ -427,6 +441,7 @@ 'original_name': 'IP address', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_ip', 'unique_id': '8516fbb1-17f1-4194-0000000_network_ip', @@ -474,6 +489,7 @@ 'original_name': 'Lens brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_lens_brush', 'unique_id': '8516fbb1-17f1-4194-0000000_lifespan_lens_brush', @@ -518,12 +534,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total area cleaned', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_stats_area', 'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_area', @@ -570,6 +590,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -579,6 +602,7 @@ 'original_name': 'Total cleaning duration', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_stats_time', 'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_time', @@ -598,7 +622,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '40.000', + 'state': '40.0', }) # --- # name: test_sensors[5xu9h3][sensor.goat_g1_total_cleanings:entity-registry] @@ -631,6 +655,7 @@ 'original_name': 'Total cleanings', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_stats_cleanings', 'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_cleanings', @@ -679,6 +704,7 @@ 'original_name': 'Wi-Fi RSSI', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_rssi', 'unique_id': '8516fbb1-17f1-4194-0000000_network_rssi', @@ -726,6 +752,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_ssid', 'unique_id': '8516fbb1-17f1-4194-0000000_network_ssid', @@ -767,6 +794,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -776,6 +806,7 @@ 'original_name': 'Area cleaned', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stats_area', 'unique_id': '8516fbb1-17f1-4194-0000001_stats_area', @@ -825,6 +856,7 @@ 'original_name': 'Battery', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '8516fbb1-17f1-4194-0000001_battery_level', @@ -868,6 +900,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -877,6 +912,7 @@ 'original_name': 'Cleaning duration', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stats_time', 'unique_id': '8516fbb1-17f1-4194-0000001_stats_time', @@ -926,6 +962,7 @@ 'original_name': 'Error', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error', 'unique_id': '8516fbb1-17f1-4194-0000001_error', @@ -974,6 +1011,7 @@ 'original_name': 'Filter lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_filter', 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_filter', @@ -1022,6 +1060,7 @@ 'original_name': 'IP address', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_ip', 'unique_id': '8516fbb1-17f1-4194-0000001_network_ip', @@ -1069,6 +1108,7 @@ 'original_name': 'Main brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_brush', 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_brush', @@ -1117,6 +1157,7 @@ 'original_name': 'Round mop lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_round_mop', 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_round_mop', @@ -1165,6 +1206,7 @@ 'original_name': 'Side brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_side_brush', 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_side_brush', @@ -1218,6 +1260,7 @@ 'original_name': 'Station state', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'station_state', 'unique_id': '8516fbb1-17f1-4194-0000001_station_state', @@ -1266,12 +1309,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total area cleaned', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_stats_area', 'unique_id': '8516fbb1-17f1-4194-0000001_total_stats_area', @@ -1318,6 +1365,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1327,6 +1377,7 @@ 'original_name': 'Total cleaning duration', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_stats_time', 'unique_id': '8516fbb1-17f1-4194-0000001_total_stats_time', @@ -1346,7 +1397,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '40.000', + 'state': '40.0', }) # --- # name: test_sensors[qhe2o2][sensor.dusty_total_cleanings:entity-registry] @@ -1379,6 +1430,7 @@ 'original_name': 'Total cleanings', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_stats_cleanings', 'unique_id': '8516fbb1-17f1-4194-0000001_total_stats_cleanings', @@ -1427,6 +1479,7 @@ 'original_name': 'Unit care lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_unit_care', 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_unit_care', @@ -1475,6 +1528,7 @@ 'original_name': 'Wi-Fi RSSI', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_rssi', 'unique_id': '8516fbb1-17f1-4194-0000001_network_rssi', @@ -1522,6 +1576,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_ssid', 'unique_id': '8516fbb1-17f1-4194-0000001_network_ssid', @@ -1563,6 +1618,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1572,6 +1630,7 @@ 'original_name': 'Area cleaned', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stats_area', 'unique_id': 'E1234567890000000001_stats_area', @@ -1621,6 +1680,7 @@ 'original_name': 'Battery', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'E1234567890000000001_battery_level', @@ -1664,6 +1724,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1673,6 +1736,7 @@ 'original_name': 'Cleaning duration', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stats_time', 'unique_id': 'E1234567890000000001_stats_time', @@ -1722,6 +1786,7 @@ 'original_name': 'Error', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error', 'unique_id': 'E1234567890000000001_error', @@ -1770,6 +1835,7 @@ 'original_name': 'Filter lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_filter', 'unique_id': 'E1234567890000000001_lifespan_filter', @@ -1818,6 +1884,7 @@ 'original_name': 'IP address', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_ip', 'unique_id': 'E1234567890000000001_network_ip', @@ -1865,6 +1932,7 @@ 'original_name': 'Main brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_brush', 'unique_id': 'E1234567890000000001_lifespan_brush', @@ -1913,6 +1981,7 @@ 'original_name': 'Side brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifespan_side_brush', 'unique_id': 'E1234567890000000001_lifespan_side_brush', @@ -1957,12 +2026,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total area cleaned', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_stats_area', 'unique_id': 'E1234567890000000001_total_stats_area', @@ -2009,6 +2082,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -2018,6 +2094,7 @@ 'original_name': 'Total cleaning duration', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_stats_time', 'unique_id': 'E1234567890000000001_total_stats_time', @@ -2037,7 +2114,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '40.000', + 'state': '40.0', }) # --- # name: test_sensors[yna5x1][sensor.ozmo_950_total_cleanings:entity-registry] @@ -2070,6 +2147,7 @@ 'original_name': 'Total cleanings', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_stats_cleanings', 'unique_id': 'E1234567890000000001_total_stats_cleanings', @@ -2118,6 +2196,7 @@ 'original_name': 'Wi-Fi RSSI', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_rssi', 'unique_id': 'E1234567890000000001_network_rssi', @@ -2165,6 +2244,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_ssid', 'unique_id': 'E1234567890000000001_network_ssid', diff --git a/tests/components/ecovacs/snapshots/test_switch.ambr b/tests/components/ecovacs/snapshots/test_switch.ambr index 48aa9d8fc17..e56142c2d82 100644 --- a/tests/components/ecovacs/snapshots/test_switch.ambr +++ b/tests/components/ecovacs/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Advanced mode', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'advanced_mode', 'unique_id': '8516fbb1-17f1-4194-0000000_advanced_mode', @@ -74,6 +75,7 @@ 'original_name': 'Border switch', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'border_switch', 'unique_id': '8516fbb1-17f1-4194-0000000_border_switch', @@ -121,6 +123,7 @@ 'original_name': 'Child lock', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': '8516fbb1-17f1-4194-0000000_child_lock', @@ -168,6 +171,7 @@ 'original_name': 'Cross map border warning', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cross_map_border_warning', 'unique_id': '8516fbb1-17f1-4194-0000000_cross_map_border_warning', @@ -215,6 +219,7 @@ 'original_name': 'Move up warning', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'move_up_warning', 'unique_id': '8516fbb1-17f1-4194-0000000_move_up_warning', @@ -262,6 +267,7 @@ 'original_name': 'Safe protect', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'safe_protect', 'unique_id': '8516fbb1-17f1-4194-0000000_safe_protect', @@ -309,6 +315,7 @@ 'original_name': 'True detect', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'true_detect', 'unique_id': '8516fbb1-17f1-4194-0000000_true_detect', @@ -356,6 +363,7 @@ 'original_name': 'Advanced mode', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'advanced_mode', 'unique_id': 'E1234567890000000001_advanced_mode', @@ -403,6 +411,7 @@ 'original_name': 'Carpet auto-boost suction', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'carpet_auto_fan_boost', 'unique_id': 'E1234567890000000001_carpet_auto_fan_boost', @@ -450,6 +459,7 @@ 'original_name': 'Continuous cleaning', 'platform': 'ecovacs', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'continuous_cleaning', 'unique_id': 'E1234567890000000001_continuous_cleaning', diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py index 16e2d3fefc5..0a39d3f2623 100644 --- a/tests/components/ecovacs/test_binary_sensor.py +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -2,7 +2,7 @@ from deebot_client.events.water_info import MopAttachedEvent import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index 3021db62e6f..30a7db431d0 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -9,7 +9,7 @@ from deebot_client.commands.json import ( ) from deebot_client.events import LifeSpan import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.ecovacs.const import DOMAIN diff --git a/tests/components/ecovacs/test_event.py b/tests/components/ecovacs/test_event.py index 03fb79e083f..56a0298bef1 100644 --- a/tests/components/ecovacs/test_event.py +++ b/tests/components/ecovacs/test_event.py @@ -5,7 +5,7 @@ from datetime import timedelta from deebot_client.events import CleanJobStatus, ReportStatsEvent from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 13b73d853d5..c0e5ce143c9 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch from deebot_client.exceptions import DeebotError, InvalidAuthenticationError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_lawn_mower.py b/tests/components/ecovacs/test_lawn_mower.py index 2c0abd0a49e..bab1495e16c 100644 --- a/tests/components/ecovacs/test_lawn_mower.py +++ b/tests/components/ecovacs/test_lawn_mower.py @@ -7,7 +7,7 @@ from deebot_client.commands.json import Charge, CleanV2 from deebot_client.events import StateEvent from deebot_client.models import CleanAction, State import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index 32bc8f90696..dd7308e18fd 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -6,7 +6,7 @@ from deebot_client.command import Command from deebot_client.commands.json import SetCutDirection, SetVolume from deebot_client.events import CutDirectionEvent, Event, VolumeEvent import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index 1e03bb18e28..c3025d99cfa 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -5,7 +5,7 @@ from deebot_client.commands.json import SetWaterInfo from deebot_client.event_bus import EventBus from deebot_client.events.water_info import WaterAmount, WaterAmountEvent import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import select from homeassistant.components.ecovacs.const import DOMAIN diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 8222e9976d5..6c3900ccd19 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -14,7 +14,7 @@ from deebot_client.events import ( station, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index 040528debaa..23c802fa0ef 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -27,7 +27,7 @@ from deebot_client.events import ( TrueDetectEvent, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/eddystone_temperature/__init__.py b/tests/components/eddystone_temperature/__init__.py new file mode 100644 index 00000000000..af67530c946 --- /dev/null +++ b/tests/components/eddystone_temperature/__init__.py @@ -0,0 +1 @@ +"""Tests for eddystone temperature.""" diff --git a/tests/components/eddystone_temperature/test_sensor.py b/tests/components/eddystone_temperature/test_sensor.py new file mode 100644 index 00000000000..056681fdb90 --- /dev/null +++ b/tests/components/eddystone_temperature/test_sensor.py @@ -0,0 +1,45 @@ +"""Tests for eddystone temperature.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.eddystone_temperature import ( + CONF_BEACONS, + CONF_INSTANCE, + CONF_NAMESPACE, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", beacontools=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + PLATFORM_DOMAIN, + { + PLATFORM_DOMAIN: [ + { + CONF_PLATFORM: DOMAIN, + CONF_BEACONS: { + "living_room": { + CONF_NAMESPACE: "112233445566778899AA", + CONF_INSTANCE: "000000000001", + } + }, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index 654028c7c11..c05e95701e1 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -1,7 +1,6 @@ """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 @@ -9,12 +8,13 @@ from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub from eheimdigital.types import ( - EheimDeviceType, - FilterErrorCode, - FilterMode, - HeaterMode, - HeaterUnit, - LightMode, + AcclimatePacket, + CCVPacket, + ClassicVarioDataPacket, + ClockPacket, + CloudPacket, + MoonPacket, + UsrDtaPacket, ) import pytest @@ -22,7 +22,7 @@ from homeassistant.components.eheimdigital.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -36,64 +36,50 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture def classic_led_ctrl_mock(): """Mock a classicLEDcontrol device.""" - classic_led_ctrl_mock = MagicMock(spec=EheimDigitalClassicLEDControl) - classic_led_ctrl_mock.tankconfig = [["CLASSIC_DAYLIGHT"], []] - classic_led_ctrl_mock.mac_address = "00:00:00:00:00:01" - classic_led_ctrl_mock.device_type = ( - EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + classic_led_ctrl = EheimDigitalClassicLEDControl( + MagicMock(spec=EheimDigitalHub), + UsrDtaPacket(load_json_object_fixture("classic_led_ctrl/usrdta.json", DOMAIN)), ) - classic_led_ctrl_mock.name = "Mock classicLEDcontrol+e" - classic_led_ctrl_mock.aquarium_name = "Mock Aquarium" - classic_led_ctrl_mock.sw_version = "1.0.0_1.0.0" - classic_led_ctrl_mock.light_mode = LightMode.DAYCL_MODE - classic_led_ctrl_mock.light_level = (10, 39) - return classic_led_ctrl_mock + classic_led_ctrl.ccv = CCVPacket( + load_json_object_fixture("classic_led_ctrl/ccv.json", DOMAIN) + ) + classic_led_ctrl.moon = MoonPacket( + load_json_object_fixture("classic_led_ctrl/moon.json", DOMAIN) + ) + classic_led_ctrl.acclimate = AcclimatePacket( + load_json_object_fixture("classic_led_ctrl/acclimate.json", DOMAIN) + ) + classic_led_ctrl.cloud = CloudPacket( + load_json_object_fixture("classic_led_ctrl/cloud.json", DOMAIN) + ) + classic_led_ctrl.clock = ClockPacket( + load_json_object_fixture("classic_led_ctrl/clock.json", DOMAIN) + ) + return classic_led_ctrl @pytest.fixture def heater_mock(): """Mock a Heater device.""" - heater_mock = MagicMock(spec=EheimDigitalHeater) - heater_mock.mac_address = "00:00:00:00:00:02" - heater_mock.device_type = EheimDeviceType.VERSION_EHEIM_EXT_HEATER - heater_mock.name = "Mock Heater" - heater_mock.aquarium_name = "Mock Aquarium" - heater_mock.sw_version = "1.0.0_1.0.0" - 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 + heater = EheimDigitalHeater( + MagicMock(spec=EheimDigitalHub), + load_json_object_fixture("heater/usrdta.json", DOMAIN), + ) + heater.heater_data = load_json_object_fixture("heater/heater_data.json", DOMAIN) + return heater @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 = EheimDigitalClassicVario( + MagicMock(spec=EheimDigitalHub), + UsrDtaPacket(load_json_object_fixture("classic_vario/usrdta.json", DOMAIN)), ) - 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 + classic_vario.classic_vario_data = ClassicVarioDataPacket( + load_json_object_fixture("classic_vario/classic_vario_data.json", DOMAIN) + ) + return classic_vario @pytest.fixture diff --git a/tests/components/eheimdigital/fixtures/classic_led_ctrl/acclimate.json b/tests/components/eheimdigital/fixtures/classic_led_ctrl/acclimate.json new file mode 100644 index 00000000000..43159de0488 --- /dev/null +++ b/tests/components/eheimdigital/fixtures/classic_led_ctrl/acclimate.json @@ -0,0 +1,9 @@ +{ + "title": "ACCLIMATE", + "from": "00:00:00:00:00:01", + "duration": 30, + "intensityReduction": 99, + "currentAcclDay": 0, + "acclActive": 0, + "pause": 0 +} diff --git a/tests/components/eheimdigital/fixtures/classic_led_ctrl/ccv.json b/tests/components/eheimdigital/fixtures/classic_led_ctrl/ccv.json new file mode 100644 index 00000000000..68f07d97d64 --- /dev/null +++ b/tests/components/eheimdigital/fixtures/classic_led_ctrl/ccv.json @@ -0,0 +1 @@ +{ "title": "CCV", "from": "00:00:00:00:00:01", "currentValues": [10, 39] } diff --git a/tests/components/eheimdigital/fixtures/classic_led_ctrl/clock.json b/tests/components/eheimdigital/fixtures/classic_led_ctrl/clock.json new file mode 100644 index 00000000000..0606e0154b6 --- /dev/null +++ b/tests/components/eheimdigital/fixtures/classic_led_ctrl/clock.json @@ -0,0 +1,13 @@ +{ + "title": "CLOCK", + "from": "00:00:00:00:00:01", + "year": 2025, + "month": 5, + "day": 22, + "hour": 5, + "min": 53, + "sec": 22, + "mode": "DAYCL_MODE", + "valid": 1, + "to": "USER" +} diff --git a/tests/components/eheimdigital/fixtures/classic_led_ctrl/cloud.json b/tests/components/eheimdigital/fixtures/classic_led_ctrl/cloud.json new file mode 100644 index 00000000000..d7e18e75943 --- /dev/null +++ b/tests/components/eheimdigital/fixtures/classic_led_ctrl/cloud.json @@ -0,0 +1,12 @@ +{ + "title": "CLOUD", + "from": "00:00:00:00:00:01", + "probability": 50, + "maxAmount": 90, + "minIntensity": 60, + "maxIntensity": 100, + "minDuration": 600, + "maxDuration": 1500, + "cloudActive": 1, + "mode": 2 +} diff --git a/tests/components/eheimdigital/fixtures/classic_led_ctrl/moon.json b/tests/components/eheimdigital/fixtures/classic_led_ctrl/moon.json new file mode 100644 index 00000000000..6a8ba896902 --- /dev/null +++ b/tests/components/eheimdigital/fixtures/classic_led_ctrl/moon.json @@ -0,0 +1,8 @@ +{ + "title": "MOON", + "from": "00:00:00:00:00:01", + "maxmoonlight": 18, + "minmoonlight": 4, + "moonlightActive": 1, + "moonlightCycle": 1 +} diff --git a/tests/components/eheimdigital/fixtures/classic_led_ctrl/usrdta.json b/tests/components/eheimdigital/fixtures/classic_led_ctrl/usrdta.json new file mode 100644 index 00000000000..332e72faabd --- /dev/null +++ b/tests/components/eheimdigital/fixtures/classic_led_ctrl/usrdta.json @@ -0,0 +1,35 @@ +{ + "title": "USRDTA", + "from": "00:00:00:00:00:01", + "name": "Mock classicLEDcontrol+e", + "aqName": "Mock Aquarium", + "mode": "DAYCL_MODE", + "version": 17, + "language": "EN", + "timezone": 60, + "tID": 30, + "dst": 1, + "tankconfig": "[[],[\"CLASSIC_DAYLIGHT\"]]", + "power": "[[],[14]]", + "netmode": "ST", + "host": "eheimdigital", + "groupID": 0, + "meshing": 1, + "firstStart": 0, + "revision": [2034, 2034], + "build": ["1722600896000", "1722596503307"], + "latestAvailableRevision": [-1, -1, -1, -1], + "firmwareAvailable": 0, + "softChange": 0, + "emailAddr": "", + "stMail": 0, + "stMailMode": 0, + "fstTime": 0, + "sstTime": 0, + "liveTime": 832140, + "usrName": "", + "unit": 0, + "demoUse": 0, + "sysLED": 20, + "to": "USER" +} diff --git a/tests/components/eheimdigital/fixtures/classic_vario/classic_vario_data.json b/tests/components/eheimdigital/fixtures/classic_vario/classic_vario_data.json new file mode 100644 index 00000000000..4065818483c --- /dev/null +++ b/tests/components/eheimdigital/fixtures/classic_vario/classic_vario_data.json @@ -0,0 +1,22 @@ +{ + "title": "CLASSIC_VARIO_DATA", + "from": "00:00:00:00:00:03", + "rel_speed": 75, + "pumpMode": 16, + "filterActive": 1, + "turnOffTime": 0, + "serviceHour": 360, + "rel_manual_motor_speed": 75, + "rel_motor_speed_day": 80, + "rel_motor_speed_night": 20, + "startTime_day": 480, + "startTime_night": 1200, + "pulse_motorSpeed_High": 100, + "pulse_motorSpeed_Low": 20, + "pulse_Time_High": 100, + "pulse_Time_Low": 50, + "turnTimeFeeding": 0, + "errorCode": 0, + "version": 0, + "to": "USER" +} diff --git a/tests/components/eheimdigital/fixtures/classic_vario/usrdta.json b/tests/components/eheimdigital/fixtures/classic_vario/usrdta.json new file mode 100644 index 00000000000..9c3535e9494 --- /dev/null +++ b/tests/components/eheimdigital/fixtures/classic_vario/usrdta.json @@ -0,0 +1,34 @@ +{ + "title": "USRDTA", + "from": "00:00:00:00:00:03", + "name": "Mock classicVARIO", + "aqName": "Mock Aquarium", + "version": 18, + "language": "EN", + "timezone": 60, + "tID": 30, + "dst": 1, + "tankconfig": "CLASSIC-VARIO", + "power": "9", + "netmode": "ST", + "host": "eheimdigital", + "groupID": 0, + "meshing": 1, + "firstStart": 0, + "revision": [2034, 2034], + "build": ["1722600896000", "1722596503307"], + "latestAvailableRevision": [1024, 1028, 2036, 2036], + "firmwareAvailable": 1, + "softChange": 0, + "emailAddr": "", + "stMail": 0, + "stMailMode": 0, + "fstTime": 720, + "sstTime": 0, + "liveTime": 444600, + "usrName": "", + "unit": 0, + "demoUse": 0, + "sysLED": 100, + "to": "USER" +} diff --git a/tests/components/eheimdigital/fixtures/heater/heater_data.json b/tests/components/eheimdigital/fixtures/heater/heater_data.json new file mode 100644 index 00000000000..ad8ef1be17d --- /dev/null +++ b/tests/components/eheimdigital/fixtures/heater/heater_data.json @@ -0,0 +1,20 @@ +{ + "title": "HEATER_DATA", + "from": "00:00:00:00:00:02", + "mUnit": 0, + "sollTemp": 255, + "isTemp": 242, + "hystLow": 5, + "hystHigh": 5, + "offset": 1, + "active": 1, + "isHeating": 1, + "mode": 0, + "sync": "", + "partnerName": "", + "dayStartT": 480, + "nightStartT": 1200, + "nReduce": -2, + "alertState": 0, + "to": "USER" +} diff --git a/tests/components/eheimdigital/fixtures/heater/usrdta.json b/tests/components/eheimdigital/fixtures/heater/usrdta.json new file mode 100644 index 00000000000..c243ebb03bd --- /dev/null +++ b/tests/components/eheimdigital/fixtures/heater/usrdta.json @@ -0,0 +1,34 @@ +{ + "title": "USRDTA", + "from": "00:00:00:00:00:02", + "name": "Mock Heater", + "aqName": "Mock Aquarium", + "version": 5, + "language": "EN", + "timezone": 60, + "tID": 30, + "dst": 1, + "tankconfig": "HEAT400", + "power": "9", + "netmode": "ST", + "host": "eheimdigital", + "groupID": 0, + "meshing": 1, + "firstStart": 0, + "remote": 0, + "revision": [1021, 1024], + "build": ["1718889198000", "1718868200327"], + "latestAvailableRevision": [-1, -1, -1, -1], + "firmwareAvailable": 0, + "emailAddr": "", + "stMail": 0, + "stMailMode": 0, + "fstTime": 0, + "sstTime": 0, + "liveTime": 302580, + "usrName": "", + "unit": 0, + "demoUse": 0, + "sysLED": 20, + "to": "USER" +} diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index 73c7cf638e8..24b503f2ed7 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -40,6 +40,7 @@ 'original_name': None, 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'heater', 'unique_id': '00:00:00:00:00:02', @@ -117,6 +118,7 @@ 'original_name': None, 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'heater', 'unique_id': '00:00:00:00:00:02', diff --git a/tests/components/eheimdigital/snapshots/test_diagnostics.ambr b/tests/components/eheimdigital/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a60952b0ef5 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_diagnostics.ambr @@ -0,0 +1,261 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + '00:00:00:00:00:01': dict({ + 'acclimate': dict({ + 'acclActive': 0, + 'currentAcclDay': 0, + 'duration': 30, + 'from': '00:00:00:00:00:01', + 'intensityReduction': 99, + 'pause': 0, + 'title': 'ACCLIMATE', + }), + 'ccv': dict({ + 'currentValues': list([ + 10, + 39, + ]), + 'from': '00:00:00:00:00:01', + 'title': 'CCV', + }), + 'clock': dict({ + 'day': 22, + 'from': '00:00:00:00:00:01', + 'hour': 5, + 'min': 53, + 'mode': 'DAYCL_MODE', + 'month': 5, + 'sec': 22, + 'title': 'CLOCK', + 'to': 'USER', + 'valid': 1, + 'year': 2025, + }), + 'cloud': dict({ + 'cloudActive': 1, + 'from': '00:00:00:00:00:01', + 'maxAmount': 90, + 'maxDuration': 1500, + 'maxIntensity': 100, + 'minDuration': 600, + 'minIntensity': 60, + 'mode': 2, + 'probability': 50, + 'title': 'CLOUD', + }), + 'moon': dict({ + 'from': '00:00:00:00:00:01', + 'maxmoonlight': 18, + 'minmoonlight': 4, + 'moonlightActive': 1, + 'moonlightCycle': 1, + 'title': 'MOON', + }), + 'usrdta': dict({ + 'aqName': 'Mock Aquarium', + 'build': list([ + '1722600896000', + '1722596503307', + ]), + 'demoUse': 0, + 'dst': 1, + 'emailAddr': '', + 'firmwareAvailable': 0, + 'firstStart': 0, + 'from': '00:00:00:00:00:01', + 'fstTime': 0, + 'groupID': 0, + 'host': 'eheimdigital', + 'language': 'EN', + 'latestAvailableRevision': list([ + -1, + -1, + -1, + -1, + ]), + 'liveTime': 832140, + 'meshing': 1, + 'mode': 'DAYCL_MODE', + 'name': 'Mock classicLEDcontrol+e', + 'netmode': 'ST', + 'power': '[[],[14]]', + 'revision': list([ + 2034, + 2034, + ]), + 'softChange': 0, + 'sstTime': 0, + 'stMail': 0, + 'stMailMode': 0, + 'sysLED': 20, + 'tID': 30, + 'tankconfig': '[[],["CLASSIC_DAYLIGHT"]]', + 'timezone': 60, + 'title': 'USRDTA', + 'to': 'USER', + 'unit': 0, + 'usrName': '', + 'version': 17, + }), + }), + '00:00:00:00:00:02': dict({ + 'heater_data': dict({ + 'active': 1, + 'alertState': 0, + 'dayStartT': 480, + 'from': '00:00:00:00:00:02', + 'hystHigh': 5, + 'hystLow': 5, + 'isHeating': 1, + 'isTemp': 242, + 'mUnit': 0, + 'mode': 0, + 'nReduce': -2, + 'nightStartT': 1200, + 'offset': 1, + 'partnerName': '', + 'sollTemp': 255, + 'sync': '', + 'title': 'HEATER_DATA', + 'to': 'USER', + }), + 'usrdta': dict({ + 'aqName': 'Mock Aquarium', + 'build': list([ + '1718889198000', + '1718868200327', + ]), + 'demoUse': 0, + 'dst': 1, + 'emailAddr': '', + 'firmwareAvailable': 0, + 'firstStart': 0, + 'from': '00:00:00:00:00:02', + 'fstTime': 0, + 'groupID': 0, + 'host': 'eheimdigital', + 'language': 'EN', + 'latestAvailableRevision': list([ + -1, + -1, + -1, + -1, + ]), + 'liveTime': 302580, + 'meshing': 1, + 'name': 'Mock Heater', + 'netmode': 'ST', + 'power': '9', + 'remote': 0, + 'revision': list([ + 1021, + 1024, + ]), + 'sstTime': 0, + 'stMail': 0, + 'stMailMode': 0, + 'sysLED': 20, + 'tID': 30, + 'tankconfig': 'HEAT400', + 'timezone': 60, + 'title': 'USRDTA', + 'to': 'USER', + 'unit': 0, + 'usrName': '', + 'version': 5, + }), + }), + '00:00:00:00:00:03': dict({ + 'classic_vario_data': dict({ + 'errorCode': 0, + 'filterActive': 1, + 'from': '00:00:00:00:00:03', + 'pulse_Time_High': 100, + 'pulse_Time_Low': 50, + 'pulse_motorSpeed_High': 100, + 'pulse_motorSpeed_Low': 20, + 'pumpMode': 16, + 'rel_manual_motor_speed': 75, + 'rel_motor_speed_day': 80, + 'rel_motor_speed_night': 20, + 'rel_speed': 75, + 'serviceHour': 360, + 'startTime_day': 480, + 'startTime_night': 1200, + 'title': 'CLASSIC_VARIO_DATA', + 'to': 'USER', + 'turnOffTime': 0, + 'turnTimeFeeding': 0, + 'version': 0, + }), + 'usrdta': dict({ + 'aqName': 'Mock Aquarium', + 'build': list([ + '1722600896000', + '1722596503307', + ]), + 'demoUse': 0, + 'dst': 1, + 'emailAddr': '', + 'firmwareAvailable': 1, + 'firstStart': 0, + 'from': '00:00:00:00:00:03', + 'fstTime': 720, + 'groupID': 0, + 'host': 'eheimdigital', + 'language': 'EN', + 'latestAvailableRevision': list([ + 1024, + 1028, + 2036, + 2036, + ]), + 'liveTime': 444600, + 'meshing': 1, + 'name': 'Mock classicVARIO', + 'netmode': 'ST', + 'power': '9', + 'revision': list([ + 2034, + 2034, + ]), + 'softChange': 0, + 'sstTime': 0, + 'stMail': 0, + 'stMailMode': 0, + 'sysLED': 100, + 'tID': 30, + 'tankconfig': 'CLASSIC-VARIO', + 'timezone': 60, + 'title': 'USRDTA', + 'to': 'USER', + 'unit': 0, + 'usrName': '', + 'version': 18, + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': 'eheimdigital', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'eheimdigital', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': '00:00:00:00:00:01', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/eheimdigital/snapshots/test_light.ambr b/tests/components/eheimdigital/snapshots/test_light.ambr index a8b454f416e..f9dedeb5cfc 100644 --- a/tests/components/eheimdigital/snapshots/test_light.ambr +++ b/tests/components/eheimdigital/snapshots/test_light.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_0-entry] +# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -19,7 +19,7 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': None, - 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -31,32 +31,33 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Channel 0', + 'original_name': 'Channel 1', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'channel', - 'unique_id': '00:00:00:00:00:01_0', + 'unique_id': '00:00:00:00:00:01_1', 'unit_of_measurement': None, }) # --- -# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_0-state] +# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': 26, + 'brightness': 99, 'color_mode': , 'effect': 'daycl_mode', 'effect_list': list([ 'daycl_mode', ]), - 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'friendly_name': 'Mock classicLEDcontrol+e Channel 1', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', 'last_changed': , 'last_reported': , 'last_updated': , @@ -98,6 +99,7 @@ 'original_name': 'Channel 0', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'channel', 'unique_id': '00:00:00:00:00:01_0', @@ -162,6 +164,7 @@ 'original_name': 'Channel 1', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'channel', 'unique_id': '00:00:00:00:00:01_1', @@ -226,6 +229,7 @@ 'original_name': 'Channel 0', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'channel', 'unique_id': '00:00:00:00:00:01_0', @@ -290,6 +294,7 @@ 'original_name': 'Channel 1', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'channel', 'unique_id': '00:00:00:00:00:01_1', diff --git a/tests/components/eheimdigital/snapshots/test_number.ambr b/tests/components/eheimdigital/snapshots/test_number.ambr index d647b16bf49..4f3b0e46287 100644 --- a/tests/components/eheimdigital/snapshots/test_number.ambr +++ b/tests/components/eheimdigital/snapshots/test_number.ambr @@ -1,4 +1,62 @@ # serializer version: 1 +# name: test_setup[number.mock_classicledcontrol_e_system_led_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicledcontrol_e_system_led_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'System LED brightness', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'system_led', + 'unique_id': '00:00:00:00:00:01_system_led', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicledcontrol_e_system_led_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicLEDcontrol+e System LED brightness', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicledcontrol_e_system_led_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup[number.mock_classicvario_day_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -32,6 +90,7 @@ 'original_name': 'Day speed', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'day_speed', 'unique_id': '00:00:00:00:00:03_day_speed', @@ -89,6 +148,7 @@ 'original_name': 'Manual speed', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'manual_speed', 'unique_id': '00:00:00:00:00:03_manual_speed', @@ -146,6 +206,7 @@ 'original_name': 'Night speed', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'night_speed', 'unique_id': '00:00:00:00:00:03_night_speed', @@ -170,6 +231,64 @@ 'state': 'unknown', }) # --- +# name: test_setup[number.mock_classicvario_system_led_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_system_led_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'System LED brightness', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'system_led', + 'unique_id': '00:00:00:00:00:03_system_led', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_system_led_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO System LED brightness', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_system_led_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup[number.mock_heater_night_temperature_offset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -203,6 +322,7 @@ 'original_name': 'Night temperature offset', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'night_temperature_offset', 'unique_id': '00:00:00:00:00:02_night_temperature_offset', @@ -227,6 +347,64 @@ 'state': 'unknown', }) # --- +# name: test_setup[number.mock_heater_system_led_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_heater_system_led_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'System LED brightness', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'system_led', + 'unique_id': '00:00:00:00:00:02_system_led', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_heater_system_led_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater System LED brightness', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_heater_system_led_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup[number.mock_heater_temperature_offset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -260,6 +438,7 @@ 'original_name': 'Temperature offset', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_offset', 'unique_id': '00:00:00:00:00:02_temperature_offset', diff --git a/tests/components/eheimdigital/snapshots/test_select.ambr b/tests/components/eheimdigital/snapshots/test_select.ambr new file mode 100644 index 00000000000..e7e0fee16c5 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_select.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_setup[select.mock_classicvario_filter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'pulse', + 'bio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_classicvario_filter_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': 'Filter mode', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_mode', + 'unique_id': '00:00:00:00:00:03_filter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[select.mock_classicvario_filter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Filter mode', + 'options': list([ + 'manual', + 'pulse', + 'bio', + ]), + }), + 'context': , + 'entity_id': 'select.mock_classicvario_filter_mode', + '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 index c5a3d700331..7f12e9fbf9b 100644 --- a/tests/components/eheimdigital/snapshots/test_sensor.ambr +++ b/tests/components/eheimdigital/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Current speed', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_speed', 'unique_id': '00:00:00:00:00:03_current_speed', @@ -81,6 +82,7 @@ 'original_name': 'Error code', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error_code', 'unique_id': '00:00:00:00:00:03_error_code', @@ -128,6 +130,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -137,6 +142,7 @@ 'original_name': 'Remaining hours until service', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'service_hours', 'unique_id': '00:00:00:00:00:03_service_hours', diff --git a/tests/components/eheimdigital/snapshots/test_switch.ambr b/tests/components/eheimdigital/snapshots/test_switch.ambr index 73d229cb4ba..5c5456d8840 100644 --- a/tests/components/eheimdigital/snapshots/test_switch.ambr +++ b/tests/components/eheimdigital/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_active', 'unique_id': '00:00:00:00:00:03', diff --git a/tests/components/eheimdigital/snapshots/test_time.ambr b/tests/components/eheimdigital/snapshots/test_time.ambr index bdd4bdaddb7..754846b4d2b 100644 --- a/tests/components/eheimdigital/snapshots/test_time.ambr +++ b/tests/components/eheimdigital/snapshots/test_time.ambr @@ -27,6 +27,7 @@ 'original_name': 'Day start time', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'day_start_time', 'unique_id': '00:00:00:00:00:03_day_start_time', @@ -74,6 +75,7 @@ 'original_name': 'Night start time', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'night_start_time', 'unique_id': '00:00:00:00:00:03_night_start_time', @@ -121,6 +123,7 @@ 'original_name': 'Day start time', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'day_start_time', 'unique_id': '00:00:00:00:00:02_day_start_time', @@ -168,6 +171,7 @@ 'original_name': 'Night start time', 'platform': 'eheimdigital', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'night_start_time', 'unique_id': '00:00:00:00:00:02_night_start_time', diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index 4abc33e449e..492d001953c 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch +from eheimdigital.heater import EheimDigitalHeater from eheimdigital.types import ( EheimDeviceType, EheimDigitalClientError, @@ -67,7 +68,7 @@ async def test_setup_heater( async def test_dynamic_new_devices( hass: HomeAssistant, eheimdigital_hub_mock: MagicMock, - heater_mock: MagicMock, + heater_mock: EheimDigitalHeater, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, @@ -116,7 +117,7 @@ async def test_dynamic_new_devices( async def test_set_preset_mode( hass: HomeAssistant, eheimdigital_hub_mock: MagicMock, - heater_mock: MagicMock, + heater_mock: EheimDigitalHeater, mock_config_entry: MockConfigEntry, preset_mode: str, heater_mode: HeaterMode, @@ -129,7 +130,7 @@ async def test_set_preset_mode( ) await hass.async_block_till_done() - heater_mock.set_operation_mode.side_effect = EheimDigitalClientError + heater_mock.hub.send_packet.side_effect = EheimDigitalClientError with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -139,7 +140,7 @@ async def test_set_preset_mode( blocking=True, ) - heater_mock.set_operation_mode.side_effect = None + heater_mock.hub.send_packet.side_effect = None await hass.services.async_call( CLIMATE_DOMAIN, @@ -148,7 +149,8 @@ async def test_set_preset_mode( blocking=True, ) - heater_mock.set_operation_mode.assert_awaited_with(heater_mode) + calls = [call for call in heater_mock.hub.mock_calls if call[0] == "send_packet"] + assert len(calls) == 2 and calls[1][1][0]["mode"] == int(heater_mode) async def test_set_temperature( @@ -165,7 +167,7 @@ async def test_set_temperature( ) await hass.async_block_till_done() - heater_mock.set_target_temperature.side_effect = EheimDigitalClientError + heater_mock.hub.send_packet.side_effect = EheimDigitalClientError with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -175,7 +177,7 @@ async def test_set_temperature( blocking=True, ) - heater_mock.set_target_temperature.side_effect = None + heater_mock.hub.send_packet.side_effect = None await hass.services.async_call( CLIMATE_DOMAIN, @@ -184,7 +186,8 @@ async def test_set_temperature( blocking=True, ) - heater_mock.set_target_temperature.assert_awaited_with(26.0) + calls = [call for call in heater_mock.hub.mock_calls if call[0] == "send_packet"] + assert len(calls) == 2 and calls[1][1][0]["sollTemp"] == 260 @pytest.mark.parametrize( @@ -206,7 +209,7 @@ async def test_set_hvac_mode( ) await hass.async_block_till_done() - heater_mock.set_active.side_effect = EheimDigitalClientError + heater_mock.hub.send_packet.side_effect = EheimDigitalClientError with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -216,7 +219,7 @@ async def test_set_hvac_mode( blocking=True, ) - heater_mock.set_active.side_effect = None + heater_mock.hub.send_packet.side_effect = None await hass.services.async_call( CLIMATE_DOMAIN, @@ -225,19 +228,20 @@ async def test_set_hvac_mode( blocking=True, ) - heater_mock.set_active.assert_awaited_with(active=active) + calls = [call for call in heater_mock.hub.mock_calls if call[0] == "send_packet"] + assert len(calls) == 2 and calls[1][1][0]["active"] == int(active) async def test_state_update( hass: HomeAssistant, eheimdigital_hub_mock: MagicMock, mock_config_entry: MockConfigEntry, - heater_mock: MagicMock, + heater_mock: EheimDigitalHeater, ) -> None: """Test the climate state update.""" - heater_mock.temperature_unit = HeaterUnit.FAHRENHEIT - heater_mock.is_heating = False - heater_mock.operation_mode = HeaterMode.BIO + heater_mock.heater_data["mUnit"] = int(HeaterUnit.FAHRENHEIT) + heater_mock.heater_data["isHeating"] = int(False) + heater_mock.heater_data["mode"] = int(HeaterMode.BIO) await init_integration(hass, mock_config_entry) @@ -251,8 +255,8 @@ async def test_state_update( assert state.attributes["hvac_action"] == HVACAction.IDLE assert state.attributes["preset_mode"] == HEATER_BIO_MODE - heater_mock.is_active = False - heater_mock.operation_mode = HeaterMode.SMART + heater_mock.heater_data["active"] = int(False) + heater_mock.heater_data["mode"] = int(HeaterMode.SMART) await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() diff --git a/tests/components/eheimdigital/test_diagnostics.py b/tests/components/eheimdigital/test_diagnostics.py new file mode 100644 index 00000000000..878bc1eb1cc --- /dev/null +++ b/tests/components/eheimdigital/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Tests for the diagnostics module.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from .conftest import 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_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry diagnostics.""" + + await init_integration(hass, mock_config_entry) + + for device in eheimdigital_hub_mock.return_value.devices.values(): + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + mock_config_entry.runtime_data.data = eheimdigital_hub_mock.return_value.devices + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id")) diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py index 81b63218085..c6b2063ec0c 100644 --- a/tests/components/eheimdigital/test_light.py +++ b/tests/components/eheimdigital/test_light.py @@ -4,7 +4,8 @@ from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError -from eheimdigital.types import EheimDeviceType, LightMode +from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.types import EheimDeviceType from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -117,7 +118,7 @@ async def test_dynamic_new_devices( async def test_turn_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - classic_led_ctrl_mock: MagicMock, + classic_led_ctrl_mock: EheimDigitalClassicLEDControl, ) -> None: """Test turning off the light.""" await init_integration(hass, mock_config_entry) @@ -130,12 +131,18 @@ async def test_turn_off( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0"}, + {ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_1"}, blocking=True, ) - classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.MAN_MODE) - classic_led_ctrl_mock.turn_off.assert_awaited_once_with(0) + calls = [ + call + for call in classic_led_ctrl_mock.hub.mock_calls + if call[0] == "send_packet" + ] + assert len(calls) == 2 + assert calls[0][1][0].get("title") == "MAN_MODE" + assert calls[1][1][0]["currentValues"][1] == 0 @pytest.mark.parametrize( @@ -150,7 +157,7 @@ async def test_turn_on_brightness( hass: HomeAssistant, eheimdigital_hub_mock: MagicMock, mock_config_entry: MockConfigEntry, - classic_led_ctrl_mock: MagicMock, + classic_led_ctrl_mock: EheimDigitalClassicLEDControl, dim_input: int, expected_dim_value: int, ) -> None: @@ -166,24 +173,30 @@ async def test_turn_on_brightness( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0", + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_1", ATTR_BRIGHTNESS: dim_input, }, blocking=True, ) - classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.MAN_MODE) - classic_led_ctrl_mock.turn_on.assert_awaited_once_with(expected_dim_value, 0) + calls = [ + call + for call in classic_led_ctrl_mock.hub.mock_calls + if call[0] == "send_packet" + ] + assert len(calls) == 2 + assert calls[0][1][0].get("title") == "MAN_MODE" + assert calls[1][1][0]["currentValues"][1] == expected_dim_value async def test_turn_on_effect( hass: HomeAssistant, eheimdigital_hub_mock: MagicMock, mock_config_entry: MockConfigEntry, - classic_led_ctrl_mock: MagicMock, + classic_led_ctrl_mock: EheimDigitalClassicLEDControl, ) -> None: """Test turning on the light with an effect value.""" - classic_led_ctrl_mock.light_mode = LightMode.MAN_MODE + classic_led_ctrl_mock.clock["mode"] = "MAN_MODE" await init_integration(hass, mock_config_entry) @@ -196,20 +209,26 @@ async def test_turn_on_effect( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0", + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_1", ATTR_EFFECT: EFFECT_DAYCL_MODE, }, blocking=True, ) - classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.DAYCL_MODE) + calls = [ + call + for call in classic_led_ctrl_mock.hub.mock_calls + if call[0] == "send_packet" + ] + assert len(calls) == 1 + assert calls[0][1][0].get("title") == "DAYCL_MODE" async def test_state_update( hass: HomeAssistant, eheimdigital_hub_mock: MagicMock, mock_config_entry: MockConfigEntry, - classic_led_ctrl_mock: MagicMock, + classic_led_ctrl_mock: EheimDigitalClassicLEDControl, ) -> None: """Test the light state update.""" await init_integration(hass, mock_config_entry) @@ -219,11 +238,11 @@ async def test_state_update( ) await hass.async_block_till_done() - classic_led_ctrl_mock.light_level = (20, 30) + classic_led_ctrl_mock.ccv["currentValues"] = [30, 20] await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() - assert (state := hass.states.get("light.mock_classicledcontrol_e_channel_0")) + assert (state := hass.states.get("light.mock_classicledcontrol_e_channel_1")) assert state.attributes["brightness"] == value_to_brightness((1, 100), 20) @@ -248,6 +267,6 @@ async def test_update_failed( await hass.async_block_till_done() assert ( - hass.states.get("light.mock_classicledcontrol_e_channel_0").state + hass.states.get("light.mock_classicledcontrol_e_channel_1").state == STATE_UNAVAILABLE ) diff --git a/tests/components/eheimdigital/test_number.py b/tests/components/eheimdigital/test_number.py index d84c14f95a5..5235dfcdb75 100644 --- a/tests/components/eheimdigital/test_number.py +++ b/tests/components/eheimdigital/test_number.py @@ -58,14 +58,20 @@ async def test_setup( ( "number.mock_heater_temperature_offset", 0.4, - "set_temperature_offset", - (0.4,), + "offset", + 4, ), ( "number.mock_heater_night_temperature_offset", 0.4, - "set_night_temperature_offset", - (0.4,), + "nReduce", + 4, + ), + ( + "number.mock_heater_system_led_brightness", + 20, + "sysLED", + 20, ), ], ), @@ -75,20 +81,26 @@ async def test_setup( ( "number.mock_classicvario_manual_speed", 72.1, - "set_manual_speed", - (int(72.1),), + "rel_manual_motor_speed", + int(72.1), ), ( "number.mock_classicvario_day_speed", 72.1, - "set_day_speed", - (int(72.1),), + "rel_motor_speed_day", + int(72.1), ), ( "number.mock_classicvario_night_speed", 72.1, - "set_night_speed", - (int(72.1),), + "rel_motor_speed_night", + int(72.1), + ), + ( + "number.mock_classicvario_system_led_brightness", + 20, + "sysLED", + 20, ), ], ), @@ -119,8 +131,8 @@ async def test_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] + calls = [call for call in device.hub.mock_calls if call[0] == "send_packet"] + assert calls[-1][1][0][item[2]] == item[3] @pytest.mark.usefixtures("classic_vario_mock", "heater_mock") @@ -132,13 +144,24 @@ async def test_set_value( [ ( "number.mock_heater_temperature_offset", - "temperature_offset", + "heater_data", + "offset", + -11, -1.1, ), ( "number.mock_heater_night_temperature_offset", - "night_temperature_offset", - 2.3, + "heater_data", + "nReduce", + -23, + -2.3, + ), + ( + "number.mock_heater_system_led_brightness", + "usrdta", + "sysLED", + 87, + 87, ), ], ), @@ -147,18 +170,31 @@ async def test_set_value( [ ( "number.mock_classicvario_manual_speed", - "manual_speed", + "classic_vario_data", + "rel_manual_motor_speed", + 34, 34, ), ( "number.mock_classicvario_day_speed", - "day_speed", - 79, + "classic_vario_data", + "rel_motor_speed_day", + 72, + 72, ), ( "number.mock_classicvario_night_speed", - "night_speed", - 12, + "classic_vario_data", + "rel_motor_speed_night", + 20, + 20, + ), + ( + "number.mock_classicvario_system_led_brightness", + "usrdta", + "sysLED", + 20, + 20, ), ], ), @@ -169,7 +205,7 @@ async def test_state_update( eheimdigital_hub_mock: MagicMock, mock_config_entry: MockConfigEntry, device_name: str, - entity_list: list[tuple[str, str, float]], + entity_list: list[tuple[str, str, str, float, float]], request: pytest.FixtureRequest, ) -> None: """Test state updates.""" @@ -183,7 +219,7 @@ async def test_state_update( await hass.async_block_till_done() for item in entity_list: - setattr(device, item[1], item[2]) + getattr(device, item[1])[item[2]] = item[3] await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() assert (state := hass.states.get(item[0])) - assert state.state == str(item[2]) + assert state.state == str(item[4]) diff --git a/tests/components/eheimdigital/test_select.py b/tests/components/eheimdigital/test_select.py new file mode 100644 index 00000000000..ab577bbe0aa --- /dev/null +++ b/tests/components/eheimdigital/test_select.py @@ -0,0 +1,138 @@ +"""Tests for the select module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import FilterMode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock") +async def test_setup( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test select platform setup.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SELECT]), + 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") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "classic_vario_mock", + [ + ( + "select.mock_classicvario_filter_mode", + "manual", + "pumpMode", + int(FilterMode.MANUAL), + ), + ], + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, str, int]], + 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( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: item[0], ATTR_OPTION: item[1]}, + blocking=True, + ) + calls = [call for call in device.hub.mock_calls if call[0] == "send_packet"] + assert calls[-1][1][0][item[2]] == item[3] + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "classic_vario_mock", + [ + ( + "select.mock_classicvario_filter_mode", + "classic_vario_data", + "pumpMode", + int(FilterMode.BIO), + "bio", + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, str, int, str]], + 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: + getattr(device, item[1])[item[2]] = item[3] + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert (state := hass.states.get(item[0])) + assert state.state == item[4] diff --git a/tests/components/eheimdigital/test_sensor.py b/tests/components/eheimdigital/test_sensor.py index ece4d3eb241..a2c0fae5b16 100644 --- a/tests/components/eheimdigital/test_sensor.py +++ b/tests/components/eheimdigital/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import init_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, get_sensor_display_state, snapshot_platform @pytest.mark.usefixtures("classic_vario_mock") @@ -43,35 +43,58 @@ async def test_setup_classic_vario( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("classic_vario_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "classic_vario_mock", + [ + ( + "sensor.mock_classicvario_current_speed", + "classic_vario_data", + "rel_speed", + 10, + 10, + ), + ( + "sensor.mock_classicvario_error_code", + "classic_vario_data", + "errorCode", + int(FilterErrorCode.ROTOR_STUCK), + "rotor_stuck", + ), + ( + "sensor.mock_classicvario_remaining_hours_until_service", + "classic_vario_data", + "serviceHour", + 100, + str(round(100 / 24, 2)), + ), + ], + ), + ], +) async def test_state_update( hass: HomeAssistant, + entity_registry: er.EntityRegistry, eheimdigital_hub_mock: MagicMock, mock_config_entry: MockConfigEntry, - classic_vario_mock: MagicMock, + device_name: str, + entity_list: list[tuple[str, str, str, float, float]], + request: pytest.FixtureRequest, ) -> None: """Test the sensor state update.""" + device: MagicMock = request.getfixturevalue(device_name) 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 + device.mac_address, device.device_type ) + 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)) + for item in entity_list: + getattr(device, item[1])[item[2]] = item[3] + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert get_sensor_display_state(hass, entity_registry, item[0]) == str(item[4]) diff --git a/tests/components/eheimdigital/test_switch.py b/tests/components/eheimdigital/test_switch.py index 440e4776b37..4195c059504 100644 --- a/tests/components/eheimdigital/test_switch.py +++ b/tests/components/eheimdigital/test_switch.py @@ -11,8 +11,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, - STATE_ON, Platform, ) from homeassistant.core import HomeAssistant @@ -77,29 +75,58 @@ async def test_turn_on_off( blocking=True, ) - classic_vario_mock.set_active.assert_awaited_once_with(active=active) + calls = [ + call for call in classic_vario_mock.hub.mock_calls if call[0] == "send_packet" + ] + assert len(calls) == 1 + assert calls[0][1][0].get("filterActive") == int(active) +@pytest.mark.usefixtures("classic_vario_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "classic_vario_mock", + [ + ( + "switch.mock_classicvario", + "classic_vario_data", + "filterActive", + 1, + "on", + ), + ( + "switch.mock_classicvario", + "classic_vario_data", + "filterActive", + 0, + "off", + ), + ], + ), + ], +) async def test_state_update( hass: HomeAssistant, eheimdigital_hub_mock: MagicMock, mock_config_entry: MockConfigEntry, - classic_vario_mock: MagicMock, + device_name: str, + entity_list: list[tuple[str, str, str, float, float]], + request: pytest.FixtureRequest, ) -> None: """Test the switch state update.""" + device: MagicMock = request.getfixturevalue(device_name) 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 + device.mac_address, device.device_type ) + 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 + for item in entity_list: + getattr(device, item[1])[item[2]] = item[3] + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert (state := hass.states.get(item[0])) + assert state.state == str(item[4]) diff --git a/tests/components/eheimdigital/test_time.py b/tests/components/eheimdigital/test_time.py index acb96ae4023..990a086e633 100644 --- a/tests/components/eheimdigital/test_time.py +++ b/tests/components/eheimdigital/test_time.py @@ -59,14 +59,14 @@ async def test_setup( ( "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))),), + "dayStartT", + 9 * 60, ), ( "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))),), + "nightStartT", + 19 * 60, ), ], ), @@ -76,14 +76,14 @@ async def test_setup( ( "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))),), + "startTime_day", + 9 * 60, ), ( "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))),), + "startTime_night", + 19 * 60, ), ], ), @@ -114,8 +114,8 @@ async def test_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] + calls = [call for call in device.hub.mock_calls if call[0] == "send_packet"] + assert calls[-1][1][0][item[2]] == item[3] @pytest.mark.usefixtures("classic_vario_mock", "heater_mock") @@ -127,13 +127,17 @@ async def test_set_value( [ ( "time.mock_heater_day_start_time", - "day_start_time", - time(9, 0, tzinfo=timezone(timedelta(hours=3))), + "heater_data", + "dayStartT", + 540, + time(9, 0, tzinfo=timezone(timedelta(hours=1))).isoformat(), ), ( "time.mock_heater_night_start_time", - "night_start_time", - time(19, 0, tzinfo=timezone(timedelta(hours=3))), + "heater_data", + "nightStartT", + 1140, + time(19, 0, tzinfo=timezone(timedelta(hours=1))).isoformat(), ), ], ), @@ -142,13 +146,17 @@ async def test_set_value( [ ( "time.mock_classicvario_day_start_time", - "day_start_time", - time(9, 0, tzinfo=timezone(timedelta(hours=1))), + "classic_vario_data", + "startTime_day", + 540, + time(9, 0, tzinfo=timezone(timedelta(hours=1))).isoformat(), ), ( "time.mock_classicvario_night_start_time", - "night_start_time", - time(22, 0, tzinfo=timezone(timedelta(hours=1))), + "classic_vario_data", + "startTime_night", + 1320, + time(22, 0, tzinfo=timezone(timedelta(hours=1))).isoformat(), ), ], ), @@ -159,7 +167,7 @@ async def test_state_update( eheimdigital_hub_mock: MagicMock, mock_config_entry: MockConfigEntry, device_name: str, - entity_list: list[tuple[str, str, time]], + entity_list: list[tuple[str, str, str, float, str]], request: pytest.FixtureRequest, ) -> None: """Test state updates.""" @@ -173,7 +181,7 @@ async def test_state_update( await hass.async_block_till_done() for item in entity_list: - setattr(device, item[1], item[2]) + getattr(device, item[1])[item[2]] = item[3] await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() assert (state := hass.states.get(item[0])) - assert state.state == item[2].isoformat() + assert state.state == item[4] diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 81a817f2738..2f1c2107b52 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -41,6 +41,7 @@ 'original_name': 'Identify', 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'GW24L1A02987_identify', @@ -126,6 +127,7 @@ 'original_name': 'Restart', 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'GW24L1A02987_restart', diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 84f7ca45843..16f20224079 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -73,6 +73,7 @@ 'original_name': None, 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CN11A1A00001', @@ -192,6 +193,7 @@ 'original_name': None, 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CN11A1A00001', @@ -311,6 +313,7 @@ 'original_name': None, 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CN11A1A00001', diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index f64893798e9..3592e88f975 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -48,6 +48,7 @@ 'original_name': 'Battery', 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'GW24L1A02987_battery', @@ -143,6 +144,7 @@ 'original_name': 'Battery voltage', 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage', 'unique_id': 'GW24L1A02987_voltage', @@ -238,6 +240,7 @@ 'original_name': 'Charging current', 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'input_charge_current', 'unique_id': 'GW24L1A02987_input_charge_current', @@ -330,6 +333,7 @@ 'original_name': 'Charging power', 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_power', 'unique_id': 'GW24L1A02987_charge_power', @@ -425,6 +429,7 @@ 'original_name': 'Charging voltage', 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'input_charge_voltage', 'unique_id': 'GW24L1A02987_input_charge_voltage', diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index 254e4deb7d9..f29c16d0cae 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -40,6 +40,7 @@ 'original_name': 'Energy saving', 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saving', 'unique_id': 'GW24L1A02987_energy_saving', @@ -124,6 +125,7 @@ 'original_name': 'Studio mode', 'platform': 'elgato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', 'unique_id': 'GW24L1A02987_bypass', diff --git a/tests/components/elmax/snapshots/test_alarm_control_panel.ambr b/tests/components/elmax/snapshots/test_alarm_control_panel.ambr index 2bf3aa48430..77d41d50710 100644 --- a/tests/components/elmax/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/elmax/snapshots/test_alarm_control_panel.ambr @@ -27,6 +27,7 @@ 'original_name': 'AREA 1', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '13762559c53cd093171-area-0', @@ -78,6 +79,7 @@ 'original_name': 'AREA 2', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '13762559c53cd093171-area-1', @@ -129,6 +131,7 @@ 'original_name': 'AREA 3', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '13762559c53cd093171-area-2', diff --git a/tests/components/elmax/snapshots/test_binary_sensor.ambr b/tests/components/elmax/snapshots/test_binary_sensor.ambr index 7515547406e..5fb9b9fd06e 100644 --- a/tests/components/elmax/snapshots/test_binary_sensor.ambr +++ b/tests/components/elmax/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'ZONA 01', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '13762559c53cd093171-zona-0', @@ -75,6 +76,7 @@ 'original_name': 'ZONA 02e', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '13762559c53cd093171-zona-1', @@ -123,6 +125,7 @@ 'original_name': 'ZONA 03a', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '13762559c53cd093171-zona-2', @@ -171,6 +174,7 @@ 'original_name': 'ZONA 04', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '13762559c53cd093171-zona-3', @@ -219,6 +223,7 @@ 'original_name': 'ZONA 05', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '13762559c53cd093171-zona-4', @@ -267,6 +272,7 @@ 'original_name': 'ZONA 06', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '13762559c53cd093171-zona-5', @@ -315,6 +321,7 @@ 'original_name': 'ZONA 07', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '13762559c53cd093171-zona-6', @@ -363,6 +370,7 @@ 'original_name': 'ZONA 08', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '13762559c53cd093171-zona-7', diff --git a/tests/components/elmax/snapshots/test_cover.ambr b/tests/components/elmax/snapshots/test_cover.ambr index 8cb230e1523..5d30dc6a570 100644 --- a/tests/components/elmax/snapshots/test_cover.ambr +++ b/tests/components/elmax/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': 'ESPAN.DOM.01', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '13762559c53cd093171-tapparella-0', diff --git a/tests/components/elmax/snapshots/test_switch.ambr b/tests/components/elmax/snapshots/test_switch.ambr index f5845223717..d278c3e9854 100644 --- a/tests/components/elmax/snapshots/test_switch.ambr +++ b/tests/components/elmax/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'USCITA 02', 'platform': 'elmax', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '13762559c53cd093171-uscita-1', diff --git a/tests/components/elmax/test_alarm_control_panel.py b/tests/components/elmax/test_alarm_control_panel.py index 88fc0a33c51..f7e956708ab 100644 --- a/tests/components/elmax/test_alarm_control_panel.py +++ b/tests/components/elmax/test_alarm_control_panel.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.elmax.const import POLLING_SECONDS from homeassistant.const import Platform diff --git a/tests/components/elmax/test_binary_sensor.py b/tests/components/elmax/test_binary_sensor.py index f6cead79ee7..685cf1ff7c1 100644 --- a/tests/components/elmax/test_binary_sensor.py +++ b/tests/components/elmax/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/elmax/test_cover.py b/tests/components/elmax/test_cover.py index 9fa72432072..a42c9c17122 100644 --- a/tests/components/elmax/test_cover.py +++ b/tests/components/elmax/test_cover.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/elmax/test_switch.py b/tests/components/elmax/test_switch.py index ba6efee2184..b11fe447150 100644 --- a/tests/components/elmax/test_switch.py +++ b/tests/components/elmax/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/emoncms/conftest.py b/tests/components/emoncms/conftest.py index 4bd1d68217a..100fb2bd879 100644 --- a/tests/components/emoncms/conftest.py +++ b/tests/components/emoncms/conftest.py @@ -7,14 +7,7 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN -from homeassistant.const import ( - CONF_API_KEY, - CONF_ID, - CONF_PLATFORM, - CONF_URL, - CONF_VALUE_TEMPLATE, -) -from homeassistant.helpers.typing import ConfigType +from homeassistant.const import CONF_API_KEY, CONF_URL from tests.common import MockConfigEntry @@ -50,36 +43,6 @@ FLOW_RESULT = { SENSOR_NAME = "emoncms@1.1.1.1" -YAML_BASE = { - CONF_PLATFORM: "emoncms", - CONF_API_KEY: "my_api_key", - CONF_ID: 1, - CONF_URL: "http://1.1.1.1", -} - -YAML = { - **YAML_BASE, - CONF_ONLY_INCLUDE_FEEDID: [1], -} - - -@pytest.fixture -def emoncms_yaml_config() -> ConfigType: - """Mock emoncms yaml configuration.""" - return {"sensor": YAML} - - -@pytest.fixture -def emoncms_yaml_config_with_template() -> ConfigType: - """Mock emoncms yaml conf with template parameter.""" - return {"sensor": {**YAML, CONF_VALUE_TEMPLATE: "{{ value | float + 1500 }}"}} - - -@pytest.fixture -def emoncms_yaml_config_no_include_only_feed_id() -> ConfigType: - """Mock emoncms yaml configuration without include_only_feed_id parameter.""" - return {"sensor": YAML_BASE} - @pytest.fixture def config_entry() -> MockConfigEntry: diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr index 6dc19155863..1ad7a6c3aa5 100644 --- a/tests/components/emoncms/snapshots/test_sensor.ambr +++ b/tests/components/emoncms/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature tag parameter 1', 'platform': 'emoncms', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '123-53535292-1', diff --git a/tests/components/energenie_power_sockets/snapshots/test_switch.ambr b/tests/components/energenie_power_sockets/snapshots/test_switch.ambr index 99595168157..56e6bc52361 100644 --- a/tests/components/energenie_power_sockets/snapshots/test_switch.ambr +++ b/tests/components/energenie_power_sockets/snapshots/test_switch.ambr @@ -41,6 +41,7 @@ 'original_name': 'Socket 0', 'platform': 'energenie_power_sockets', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'socket', 'unique_id': 'DYPS:00:11:22_0', @@ -89,6 +90,7 @@ 'original_name': 'Socket 1', 'platform': 'energenie_power_sockets', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'socket', 'unique_id': 'DYPS:00:11:22_1', @@ -137,6 +139,7 @@ 'original_name': 'Socket 2', 'platform': 'energenie_power_sockets', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'socket', 'unique_id': 'DYPS:00:11:22_2', @@ -185,6 +188,7 @@ 'original_name': 'Socket 3', 'platform': 'energenie_power_sockets', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'socket', 'unique_id': 'DYPS:00:11:22_3', diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a438842f8a5..a9a249a8498 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -994,7 +994,7 @@ async def test_cost_sensor_handle_late_price_sensor( @pytest.mark.parametrize( "unit", - [UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS], + [UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, UnitOfVolume.LITERS], ) async def test_cost_sensor_handle_gas( setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any], unit diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index d7f0485139f..6389ac0b372 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -856,7 +856,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_consumption_1", "beers")}, "translation_placeholders": { "energy_units": "GJ, kWh, MJ, MWh, Wh", - "gas_units": "CCF, ft³, m³", + "gas_units": "CCF, ft³, m³, L", }, }, { @@ -885,7 +885,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_price_2", "EUR/invalid")}, "translation_placeholders": { "price_units": ( - "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh, EUR/CCF, EUR/ft³, EUR/m³" + "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" ) }, }, diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py index 3fd93ee31f8..d861e1365f7 100644 --- a/tests/components/energyzero/conftest.py +++ b/tests/components/energyzero/conftest.py @@ -1,7 +1,6 @@ """Fixtures for EnergyZero integration tests.""" -from collections.abc import Generator -import json +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch from energyzero import Electricity, Gas @@ -10,7 +9,7 @@ import pytest from homeassistant.components.energyzero.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture @@ -35,17 +34,17 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_energyzero() -> Generator[MagicMock]: +async def mock_energyzero(hass: HomeAssistant) -> AsyncGenerator[MagicMock]: """Return a mocked EnergyZero client.""" with patch( "homeassistant.components.energyzero.coordinator.EnergyZero", autospec=True ) as energyzero_mock: client = energyzero_mock.return_value client.energy_prices.return_value = Electricity.from_dict( - json.loads(load_fixture("today_energy.json", DOMAIN)) + await async_load_json_object_fixture(hass, "today_energy.json", DOMAIN) ) client.gas_prices.return_value = Gas.from_dict( - json.loads(load_fixture("today_gas.json", DOMAIN)) + await async_load_json_object_fixture(hass, "today_gas.json", DOMAIN) ) yield client diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 5407ac8f0e9..c0041bc0e50 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Average - today', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_average_price', 'supported_features': 0, 'translation_key': 'average_price', 'unique_id': '12345_today_energy_average_price', @@ -78,6 +79,7 @@ 'original_name': 'Current hour', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_current_hour_price', 'supported_features': 0, 'translation_key': 'current_hour_price', 'unique_id': '12345_today_energy_current_hour_price', @@ -128,6 +130,7 @@ 'original_name': 'Time of highest price - today', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_highest_price_time', 'supported_features': 0, 'translation_key': 'highest_price_time', 'unique_id': '12345_today_energy_highest_price_time', @@ -177,6 +180,7 @@ 'original_name': 'Hours priced equal or lower than current - today', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_hours_priced_equal_or_lower', 'supported_features': 0, 'translation_key': 'hours_priced_equal_or_lower', 'unique_id': '12345_today_energy_hours_priced_equal_or_lower', @@ -226,6 +230,7 @@ 'original_name': 'Time of lowest price - today', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_lowest_price_time', 'supported_features': 0, 'translation_key': 'lowest_price_time', 'unique_id': '12345_today_energy_lowest_price_time', @@ -275,6 +280,7 @@ 'original_name': 'Highest price - today', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_max_price', 'supported_features': 0, 'translation_key': 'max_price', 'unique_id': '12345_today_energy_max_price', @@ -324,6 +330,7 @@ 'original_name': 'Lowest price - today', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_min_price', 'supported_features': 0, 'translation_key': 'min_price', 'unique_id': '12345_today_energy_min_price', @@ -373,6 +380,7 @@ 'original_name': 'Next hour', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_next_hour_price', 'supported_features': 0, 'translation_key': 'next_hour_price', 'unique_id': '12345_today_energy_next_hour_price', @@ -422,6 +430,7 @@ 'original_name': 'Current percentage of highest price - today', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_percentage_of_max', 'supported_features': 0, 'translation_key': 'percentage_of_max', 'unique_id': '12345_today_energy_percentage_of_max', @@ -473,6 +482,7 @@ 'original_name': 'Current hour', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_gas_current_hour_price', 'supported_features': 0, 'translation_key': 'current_hour_price', 'unique_id': '12345_today_gas_current_hour_price', @@ -523,6 +533,7 @@ 'original_name': 'Next hour', 'platform': 'energyzero', 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_gas_next_hour_price', 'supported_features': 0, 'translation_key': 'next_hour_price', 'unique_id': '12345_today_gas_next_hour_price', diff --git a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr index e4810c21226..bbf35621c6c 100644 --- a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Communicating', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'communicating', 'unique_id': '123456_communicating', @@ -75,6 +76,7 @@ 'original_name': 'DC switch', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dc_switch', 'unique_id': '123456_dc_switch', @@ -122,6 +124,7 @@ 'original_name': 'Communicating', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'communicating', 'unique_id': '123456_communicating', @@ -170,6 +173,7 @@ 'original_name': 'DC switch', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dc_switch', 'unique_id': '123456_dc_switch', @@ -217,6 +221,7 @@ 'original_name': 'Communicating', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'communicating', 'unique_id': '654321_communicating', @@ -265,6 +270,7 @@ 'original_name': 'Grid status', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_status', 'unique_id': '654321_mains_oper_state', diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 650fb0bb810..7eb57488d66 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -100,6 +100,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '<>_production', @@ -152,6 +153,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '<>_daily_production', @@ -202,6 +204,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '<>_seven_days_production', @@ -253,6 +256,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '<>_lifetime_production', @@ -332,12 +336,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': 'power', 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -382,6 +390,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -549,6 +558,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '<>_production', @@ -601,6 +611,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '<>_daily_production', @@ -651,6 +662,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '<>_seven_days_production', @@ -702,6 +714,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '<>_lifetime_production', @@ -781,12 +794,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': 'power', 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -831,6 +848,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -1040,6 +1058,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '<>_production', @@ -1092,6 +1111,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '<>_daily_production', @@ -1142,6 +1162,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '<>_seven_days_production', @@ -1193,6 +1214,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '<>_lifetime_production', @@ -1272,12 +1294,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': 'power', 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -1322,6 +1348,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -1539,12 +1566,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': 'power', 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -1589,6 +1620,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -1675,6 +1707,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '<>_production', @@ -1727,6 +1760,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '<>_daily_production', @@ -1777,6 +1811,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '<>_seven_days_production', @@ -1828,6 +1863,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '<>_lifetime_production', diff --git a/tests/components/enphase_envoy/snapshots/test_number.ambr b/tests/components/enphase_envoy/snapshots/test_number.ambr index eb8f5266f32..461d4028fbe 100644 --- a/tests/components/enphase_envoy/snapshots/test_number.ambr +++ b/tests/components/enphase_envoy/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reserve_soc', 'unique_id': '1234_reserve_soc', @@ -90,6 +91,7 @@ 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reserve_soc', 'unique_id': '654321_reserve_soc', @@ -148,6 +150,7 @@ 'original_name': 'Cutoff battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cutoff_battery_level', 'unique_id': '654321_relay_NC1_soc_low', @@ -205,6 +208,7 @@ 'original_name': 'Restore battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'restore_battery_level', 'unique_id': '654321_relay_NC1_soc_high', @@ -262,6 +266,7 @@ 'original_name': 'Cutoff battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cutoff_battery_level', 'unique_id': '654321_relay_NC2_soc_low', @@ -319,6 +324,7 @@ 'original_name': 'Restore battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'restore_battery_level', 'unique_id': '654321_relay_NC2_soc_high', @@ -376,6 +382,7 @@ 'original_name': 'Cutoff battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cutoff_battery_level', 'unique_id': '654321_relay_NC3_soc_low', @@ -433,6 +440,7 @@ 'original_name': 'Restore battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'restore_battery_level', 'unique_id': '654321_relay_NC3_soc_high', diff --git a/tests/components/enphase_envoy/snapshots/test_select.ambr b/tests/components/enphase_envoy/snapshots/test_select.ambr index d8238926dfd..006b2c1a3fe 100644 --- a/tests/components/enphase_envoy/snapshots/test_select.ambr +++ b/tests/components/enphase_envoy/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Storage mode', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_mode', 'unique_id': '1234_storage_mode', @@ -91,6 +92,7 @@ 'original_name': 'Storage mode', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_mode', 'unique_id': '654321_storage_mode', @@ -150,6 +152,7 @@ 'original_name': 'Generator action', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_generator_action', 'unique_id': '654321_relay_NC1_generator_action', @@ -210,6 +213,7 @@ 'original_name': 'Grid action', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_grid_action', 'unique_id': '654321_relay_NC1_grid_action', @@ -270,6 +274,7 @@ 'original_name': 'Microgrid action', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_microgrid_action', 'unique_id': '654321_relay_NC1_microgrid_action', @@ -328,6 +333,7 @@ 'original_name': 'Mode', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_mode', 'unique_id': '654321_relay_NC1_mode', @@ -386,6 +392,7 @@ 'original_name': 'Generator action', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_generator_action', 'unique_id': '654321_relay_NC2_generator_action', @@ -446,6 +453,7 @@ 'original_name': 'Grid action', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_grid_action', 'unique_id': '654321_relay_NC2_grid_action', @@ -506,6 +514,7 @@ 'original_name': 'Microgrid action', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_microgrid_action', 'unique_id': '654321_relay_NC2_microgrid_action', @@ -564,6 +573,7 @@ 'original_name': 'Mode', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_mode', 'unique_id': '654321_relay_NC2_mode', @@ -622,6 +632,7 @@ 'original_name': 'Generator action', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_generator_action', 'unique_id': '654321_relay_NC3_generator_action', @@ -682,6 +693,7 @@ 'original_name': 'Grid action', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_grid_action', 'unique_id': '654321_relay_NC3_grid_action', @@ -742,6 +754,7 @@ 'original_name': 'Microgrid action', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_microgrid_action', 'unique_id': '654321_relay_NC3_microgrid_action', @@ -800,6 +813,7 @@ 'original_name': 'Mode', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_mode', 'unique_id': '654321_relay_NC3_mode', diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 101caaf1aea..d548b2a0f93 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -35,6 +35,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '1234_production', @@ -91,6 +92,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '1234_seven_days_production', @@ -148,6 +150,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '1234_daily_production', @@ -206,6 +209,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '1234_lifetime_production', @@ -252,12 +256,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -308,6 +316,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -364,6 +373,7 @@ 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption', 'unique_id': '1234_balanced_net_consumption', @@ -422,6 +432,7 @@ 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption', 'unique_id': '1234_net_consumption', @@ -480,6 +491,7 @@ 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption', 'unique_id': '1234_consumption', @@ -538,6 +550,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '1234_production', @@ -594,6 +607,7 @@ 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption', 'unique_id': '1234_seven_days_consumption', @@ -651,6 +665,7 @@ 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption', 'unique_id': '1234_daily_consumption', @@ -707,6 +722,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '1234_seven_days_production', @@ -764,6 +780,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '1234_daily_production', @@ -819,6 +836,7 @@ 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency', 'unique_id': '1234_frequency', @@ -874,6 +892,7 @@ 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency', 'unique_id': '1234_production_ct_frequency', @@ -932,6 +951,7 @@ 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption', 'unique_id': '1234_lifetime_balanced_net_consumption', @@ -990,6 +1010,7 @@ 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption', 'unique_id': '1234_lifetime_consumption', @@ -1048,6 +1069,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '1234_lifetime_production', @@ -1106,6 +1128,7 @@ 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption', 'unique_id': '1234_lifetime_net_consumption', @@ -1164,6 +1187,7 @@ 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production', 'unique_id': '1234_lifetime_net_production', @@ -1214,6 +1238,7 @@ 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags', 'unique_id': '1234_net_consumption_ct_status_flags', @@ -1261,6 +1286,7 @@ 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags', 'unique_id': '1234_production_ct_status_flags', @@ -1314,6 +1340,7 @@ 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status', 'unique_id': '1234_net_consumption_ct_metering_status', @@ -1373,6 +1400,7 @@ 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status', 'unique_id': '1234_production_ct_metering_status', @@ -1434,6 +1462,7 @@ 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current', 'unique_id': '1234_net_ct_current', @@ -1489,6 +1518,7 @@ 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor', 'unique_id': '1234_net_ct_powerfactor', @@ -1543,6 +1573,7 @@ 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor', 'unique_id': '1234_production_ct_powerfactor', @@ -1600,6 +1631,7 @@ 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current', 'unique_id': '1234_production_ct_current', @@ -1658,6 +1690,7 @@ 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage', 'unique_id': '1234_voltage', @@ -1716,6 +1749,7 @@ 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage', 'unique_id': '1234_production_ct_voltage', @@ -1762,12 +1796,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -1818,6 +1856,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -1866,6 +1905,7 @@ 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234_acb_soc', @@ -1922,6 +1962,7 @@ 'original_name': 'Battery state', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'acb_battery_state', 'unique_id': '1234_acb_battery_state', @@ -1970,12 +2011,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234_acb_power', @@ -2019,12 +2064,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_apparent_power_mva', @@ -2074,6 +2123,7 @@ 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_soc', @@ -2123,6 +2173,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '123456_last_reported', @@ -2165,12 +2216,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_real_power_mw', @@ -2214,12 +2269,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_temperature', @@ -2263,12 +2322,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Aggregated available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'aggregated_available_energy', 'unique_id': '1234_aggregated_available_energy', @@ -2312,12 +2375,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Aggregated Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'aggregated_max_capacity', 'unique_id': '1234_aggregated_max_battery_capacity', @@ -2367,6 +2434,7 @@ 'original_name': 'Aggregated battery soc', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'aggregated_soc', 'unique_id': '1234_aggregated_soc', @@ -2410,12 +2478,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Available ACB battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'acb_available_energy', 'unique_id': '1234_acb_available_energy', @@ -2459,12 +2531,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'available_energy', 'unique_id': '1234_available_energy', @@ -2522,6 +2598,7 @@ 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption', 'unique_id': '1234_balanced_net_consumption', @@ -2572,6 +2649,7 @@ 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234_battery_level', @@ -2615,12 +2693,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_capacity', 'unique_id': '1234_max_capacity', @@ -2678,6 +2760,7 @@ 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption', 'unique_id': '1234_net_consumption', @@ -2736,6 +2819,7 @@ 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l1', @@ -2794,6 +2878,7 @@ 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l2', @@ -2852,6 +2937,7 @@ 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l3', @@ -2910,6 +2996,7 @@ 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption', 'unique_id': '1234_consumption', @@ -2968,6 +3055,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '1234_production', @@ -3024,6 +3112,7 @@ 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption', 'unique_id': '1234_seven_days_consumption', @@ -3081,6 +3170,7 @@ 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption', 'unique_id': '1234_daily_consumption', @@ -3137,6 +3227,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '1234_seven_days_production', @@ -3194,6 +3285,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '1234_daily_production', @@ -3249,6 +3341,7 @@ 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency', 'unique_id': '1234_frequency', @@ -3304,6 +3397,7 @@ 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l1', @@ -3359,6 +3453,7 @@ 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l2', @@ -3414,6 +3509,7 @@ 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l3', @@ -3469,6 +3565,7 @@ 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency', 'unique_id': '1234_production_ct_frequency', @@ -3524,6 +3621,7 @@ 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l1', @@ -3579,6 +3677,7 @@ 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l2', @@ -3634,6 +3733,7 @@ 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l3', @@ -3692,6 +3792,7 @@ 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption', 'unique_id': '1234_lifetime_balanced_net_consumption', @@ -3750,6 +3851,7 @@ 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption', 'unique_id': '1234_lifetime_consumption', @@ -3808,6 +3910,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '1234_lifetime_production', @@ -3866,6 +3969,7 @@ 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption', 'unique_id': '1234_lifetime_net_consumption', @@ -3924,6 +4028,7 @@ 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l1', @@ -3982,6 +4087,7 @@ 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l2', @@ -4040,6 +4146,7 @@ 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l3', @@ -4098,6 +4205,7 @@ 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production', 'unique_id': '1234_lifetime_net_production', @@ -4156,6 +4264,7 @@ 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l1', @@ -4214,6 +4323,7 @@ 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l2', @@ -4272,6 +4382,7 @@ 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l3', @@ -4322,6 +4433,7 @@ 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags', 'unique_id': '1234_net_consumption_ct_status_flags', @@ -4369,6 +4481,7 @@ 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l1', @@ -4416,6 +4529,7 @@ 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l2', @@ -4463,6 +4577,7 @@ 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l3', @@ -4510,6 +4625,7 @@ 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags', 'unique_id': '1234_production_ct_status_flags', @@ -4557,6 +4673,7 @@ 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l1', @@ -4604,6 +4721,7 @@ 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l2', @@ -4651,6 +4769,7 @@ 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l3', @@ -4704,6 +4823,7 @@ 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status', 'unique_id': '1234_net_consumption_ct_metering_status', @@ -4763,6 +4883,7 @@ 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l1', @@ -4822,6 +4943,7 @@ 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l2', @@ -4881,6 +5003,7 @@ 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l3', @@ -4940,6 +5063,7 @@ 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status', 'unique_id': '1234_production_ct_metering_status', @@ -4999,6 +5123,7 @@ 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l1', @@ -5058,6 +5183,7 @@ 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l2', @@ -5117,6 +5243,7 @@ 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l3', @@ -5178,6 +5305,7 @@ 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current', 'unique_id': '1234_net_ct_current', @@ -5236,6 +5364,7 @@ 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l1', @@ -5294,6 +5423,7 @@ 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l2', @@ -5352,6 +5482,7 @@ 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l3', @@ -5407,6 +5538,7 @@ 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor', 'unique_id': '1234_net_ct_powerfactor', @@ -5461,6 +5593,7 @@ 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l1', @@ -5515,6 +5648,7 @@ 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l2', @@ -5569,6 +5703,7 @@ 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l3', @@ -5623,6 +5758,7 @@ 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor', 'unique_id': '1234_production_ct_powerfactor', @@ -5677,6 +5813,7 @@ 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l1', @@ -5731,6 +5868,7 @@ 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l2', @@ -5785,6 +5923,7 @@ 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l3', @@ -5842,6 +5981,7 @@ 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current', 'unique_id': '1234_production_ct_current', @@ -5900,6 +6040,7 @@ 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l1', @@ -5958,6 +6099,7 @@ 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l2', @@ -6016,6 +6158,7 @@ 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l3', @@ -6060,12 +6203,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reserve_energy', 'unique_id': '1234_reserve_energy', @@ -6115,6 +6262,7 @@ 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reserve_soc', 'unique_id': '1234_reserve_soc', @@ -6172,6 +6320,7 @@ 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage', 'unique_id': '1234_voltage', @@ -6230,6 +6379,7 @@ 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l1', @@ -6288,6 +6438,7 @@ 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l2', @@ -6346,6 +6497,7 @@ 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l3', @@ -6404,6 +6556,7 @@ 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage', 'unique_id': '1234_production_ct_voltage', @@ -6462,6 +6615,7 @@ 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l1', @@ -6520,6 +6674,7 @@ 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l2', @@ -6578,6 +6733,7 @@ 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l3', @@ -6624,12 +6780,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -6680,6 +6840,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -6722,12 +6883,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_apparent_power_mva', @@ -6777,6 +6942,7 @@ 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_soc', @@ -6826,6 +6992,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '123456_last_reported', @@ -6868,12 +7035,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_real_power_mw', @@ -6917,12 +7088,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_temperature', @@ -6966,12 +7141,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'available_energy', 'unique_id': '1234_available_energy', @@ -7029,6 +7208,7 @@ 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption', 'unique_id': '1234_balanced_net_consumption', @@ -7079,6 +7259,7 @@ 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234_battery_level', @@ -7122,12 +7303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_capacity', 'unique_id': '1234_max_capacity', @@ -7185,6 +7370,7 @@ 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption', 'unique_id': '1234_net_consumption', @@ -7243,6 +7429,7 @@ 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l1', @@ -7301,6 +7488,7 @@ 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l2', @@ -7359,6 +7547,7 @@ 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l3', @@ -7417,6 +7606,7 @@ 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption', 'unique_id': '1234_consumption', @@ -7475,6 +7665,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '1234_production', @@ -7531,6 +7722,7 @@ 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption', 'unique_id': '1234_seven_days_consumption', @@ -7588,6 +7780,7 @@ 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption', 'unique_id': '1234_daily_consumption', @@ -7644,6 +7837,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '1234_seven_days_production', @@ -7701,6 +7895,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '1234_daily_production', @@ -7756,6 +7951,7 @@ 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency', 'unique_id': '1234_frequency', @@ -7811,6 +8007,7 @@ 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l1', @@ -7866,6 +8063,7 @@ 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l2', @@ -7921,6 +8119,7 @@ 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l3', @@ -7976,6 +8175,7 @@ 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency', 'unique_id': '1234_production_ct_frequency', @@ -8031,6 +8231,7 @@ 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l1', @@ -8086,6 +8287,7 @@ 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l2', @@ -8141,6 +8343,7 @@ 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l3', @@ -8199,6 +8402,7 @@ 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption', 'unique_id': '1234_lifetime_balanced_net_consumption', @@ -8257,6 +8461,7 @@ 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption', 'unique_id': '1234_lifetime_consumption', @@ -8315,6 +8520,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '1234_lifetime_production', @@ -8373,6 +8579,7 @@ 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption', 'unique_id': '1234_lifetime_net_consumption', @@ -8431,6 +8638,7 @@ 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l1', @@ -8489,6 +8697,7 @@ 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l2', @@ -8547,6 +8756,7 @@ 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l3', @@ -8605,6 +8815,7 @@ 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production', 'unique_id': '1234_lifetime_net_production', @@ -8663,6 +8874,7 @@ 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l1', @@ -8721,6 +8933,7 @@ 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l2', @@ -8779,6 +8992,7 @@ 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l3', @@ -8829,6 +9043,7 @@ 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags', 'unique_id': '1234_net_consumption_ct_status_flags', @@ -8876,6 +9091,7 @@ 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l1', @@ -8923,6 +9139,7 @@ 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l2', @@ -8970,6 +9187,7 @@ 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l3', @@ -9017,6 +9235,7 @@ 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags', 'unique_id': '1234_production_ct_status_flags', @@ -9064,6 +9283,7 @@ 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l1', @@ -9111,6 +9331,7 @@ 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l2', @@ -9158,6 +9379,7 @@ 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l3', @@ -9211,6 +9433,7 @@ 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status', 'unique_id': '1234_net_consumption_ct_metering_status', @@ -9270,6 +9493,7 @@ 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l1', @@ -9329,6 +9553,7 @@ 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l2', @@ -9388,6 +9613,7 @@ 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l3', @@ -9447,6 +9673,7 @@ 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status', 'unique_id': '1234_production_ct_metering_status', @@ -9506,6 +9733,7 @@ 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l1', @@ -9565,6 +9793,7 @@ 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l2', @@ -9624,6 +9853,7 @@ 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l3', @@ -9685,6 +9915,7 @@ 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current', 'unique_id': '1234_net_ct_current', @@ -9743,6 +9974,7 @@ 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l1', @@ -9801,6 +10033,7 @@ 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l2', @@ -9859,6 +10092,7 @@ 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l3', @@ -9914,6 +10148,7 @@ 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor', 'unique_id': '1234_net_ct_powerfactor', @@ -9968,6 +10203,7 @@ 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l1', @@ -10022,6 +10258,7 @@ 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l2', @@ -10076,6 +10313,7 @@ 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l3', @@ -10130,6 +10368,7 @@ 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor', 'unique_id': '1234_production_ct_powerfactor', @@ -10184,6 +10423,7 @@ 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l1', @@ -10238,6 +10478,7 @@ 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l2', @@ -10292,6 +10533,7 @@ 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l3', @@ -10349,6 +10591,7 @@ 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current', 'unique_id': '1234_production_ct_current', @@ -10407,6 +10650,7 @@ 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l1', @@ -10465,6 +10709,7 @@ 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l2', @@ -10523,6 +10768,7 @@ 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l3', @@ -10567,12 +10813,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reserve_energy', 'unique_id': '1234_reserve_energy', @@ -10622,6 +10872,7 @@ 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reserve_soc', 'unique_id': '1234_reserve_soc', @@ -10679,6 +10930,7 @@ 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage', 'unique_id': '1234_voltage', @@ -10737,6 +10989,7 @@ 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l1', @@ -10795,6 +11048,7 @@ 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l2', @@ -10853,6 +11107,7 @@ 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l3', @@ -10911,6 +11166,7 @@ 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage', 'unique_id': '1234_production_ct_voltage', @@ -10969,6 +11225,7 @@ 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l1', @@ -11027,6 +11284,7 @@ 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l2', @@ -11085,6 +11343,7 @@ 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l3', @@ -11131,12 +11390,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -11187,6 +11450,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -11229,12 +11493,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_apparent_power_mva', @@ -11284,6 +11552,7 @@ 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_soc', @@ -11333,6 +11602,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '123456_last_reported', @@ -11375,12 +11645,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_real_power_mw', @@ -11424,12 +11698,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_temperature', @@ -11479,6 +11757,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '654321_last_reported', @@ -11521,12 +11800,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '654321_temperature', @@ -11545,7 +11828,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '26', + 'state': '26.1111111111111', }) # --- # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_available_battery_energy-entry] @@ -11570,12 +11853,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'available_energy', 'unique_id': '1234_available_energy', @@ -11633,6 +11920,7 @@ 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption', 'unique_id': '1234_balanced_net_consumption', @@ -11691,6 +11979,7 @@ 'original_name': 'Balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption_phase', 'unique_id': '1234_balanced_net_consumption_l1', @@ -11749,6 +12038,7 @@ 'original_name': 'Balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption_phase', 'unique_id': '1234_balanced_net_consumption_l2', @@ -11807,6 +12097,7 @@ 'original_name': 'Balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption_phase', 'unique_id': '1234_balanced_net_consumption_l3', @@ -11857,6 +12148,7 @@ 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234_battery_level', @@ -11900,12 +12192,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_capacity', 'unique_id': '1234_max_capacity', @@ -11963,6 +12259,7 @@ 'original_name': 'Current battery discharge', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_discharge', 'unique_id': '1234_battery_discharge', @@ -12021,6 +12318,7 @@ 'original_name': 'Current battery discharge l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_discharge_phase', 'unique_id': '1234_battery_discharge_l1', @@ -12079,6 +12377,7 @@ 'original_name': 'Current battery discharge l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_discharge_phase', 'unique_id': '1234_battery_discharge_l2', @@ -12137,6 +12436,7 @@ 'original_name': 'Current battery discharge l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_discharge_phase', 'unique_id': '1234_battery_discharge_l3', @@ -12195,6 +12495,7 @@ 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption', 'unique_id': '1234_net_consumption', @@ -12253,6 +12554,7 @@ 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l1', @@ -12311,6 +12613,7 @@ 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l2', @@ -12369,6 +12672,7 @@ 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l3', @@ -12427,6 +12731,7 @@ 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption', 'unique_id': '1234_consumption', @@ -12485,6 +12790,7 @@ 'original_name': 'Current power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption_phase', 'unique_id': '1234_consumption_l1', @@ -12543,6 +12849,7 @@ 'original_name': 'Current power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption_phase', 'unique_id': '1234_consumption_l2', @@ -12601,6 +12908,7 @@ 'original_name': 'Current power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption_phase', 'unique_id': '1234_consumption_l3', @@ -12659,6 +12967,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '1234_production', @@ -12717,6 +13026,7 @@ 'original_name': 'Current power production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production_phase', 'unique_id': '1234_production_l1', @@ -12775,6 +13085,7 @@ 'original_name': 'Current power production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production_phase', 'unique_id': '1234_production_l2', @@ -12833,6 +13144,7 @@ 'original_name': 'Current power production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production_phase', 'unique_id': '1234_production_l3', @@ -12889,6 +13201,7 @@ 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption', 'unique_id': '1234_seven_days_consumption', @@ -12944,6 +13257,7 @@ 'original_name': 'Energy consumption last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption_phase', 'unique_id': '1234_seven_days_consumption_l1', @@ -12999,6 +13313,7 @@ 'original_name': 'Energy consumption last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption_phase', 'unique_id': '1234_seven_days_consumption_l2', @@ -13054,6 +13369,7 @@ 'original_name': 'Energy consumption last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption_phase', 'unique_id': '1234_seven_days_consumption_l3', @@ -13111,6 +13427,7 @@ 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption', 'unique_id': '1234_daily_consumption', @@ -13169,6 +13486,7 @@ 'original_name': 'Energy consumption today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption_phase', 'unique_id': '1234_daily_consumption_l1', @@ -13227,6 +13545,7 @@ 'original_name': 'Energy consumption today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption_phase', 'unique_id': '1234_daily_consumption_l2', @@ -13285,6 +13604,7 @@ 'original_name': 'Energy consumption today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption_phase', 'unique_id': '1234_daily_consumption_l3', @@ -13341,6 +13661,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '1234_seven_days_production', @@ -13396,6 +13717,7 @@ 'original_name': 'Energy production last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production_phase', 'unique_id': '1234_seven_days_production_l1', @@ -13451,6 +13773,7 @@ 'original_name': 'Energy production last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production_phase', 'unique_id': '1234_seven_days_production_l2', @@ -13506,6 +13829,7 @@ 'original_name': 'Energy production last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production_phase', 'unique_id': '1234_seven_days_production_l3', @@ -13563,6 +13887,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '1234_daily_production', @@ -13621,6 +13946,7 @@ 'original_name': 'Energy production today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production_phase', 'unique_id': '1234_daily_production_l1', @@ -13679,6 +14005,7 @@ 'original_name': 'Energy production today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production_phase', 'unique_id': '1234_daily_production_l2', @@ -13737,6 +14064,7 @@ 'original_name': 'Energy production today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production_phase', 'unique_id': '1234_daily_production_l3', @@ -13792,6 +14120,7 @@ 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency', 'unique_id': '1234_frequency', @@ -13847,6 +14176,7 @@ 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l1', @@ -13902,6 +14232,7 @@ 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l2', @@ -13957,6 +14288,7 @@ 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l3', @@ -14012,6 +14344,7 @@ 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency', 'unique_id': '1234_production_ct_frequency', @@ -14067,6 +14400,7 @@ 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l1', @@ -14122,6 +14456,7 @@ 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l2', @@ -14177,6 +14512,7 @@ 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l3', @@ -14232,6 +14568,7 @@ 'original_name': 'Frequency storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_frequency', 'unique_id': '1234_storage_ct_frequency', @@ -14287,6 +14624,7 @@ 'original_name': 'Frequency storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_frequency_phase', 'unique_id': '1234_storage_ct_frequency_l1', @@ -14342,6 +14680,7 @@ 'original_name': 'Frequency storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_frequency_phase', 'unique_id': '1234_storage_ct_frequency_l2', @@ -14397,6 +14736,7 @@ 'original_name': 'Frequency storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_frequency_phase', 'unique_id': '1234_storage_ct_frequency_l3', @@ -14455,6 +14795,7 @@ 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption', 'unique_id': '1234_lifetime_balanced_net_consumption', @@ -14513,6 +14854,7 @@ 'original_name': 'Lifetime balanced net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption_phase', 'unique_id': '1234_lifetime_balanced_net_consumption_l1', @@ -14571,6 +14913,7 @@ 'original_name': 'Lifetime balanced net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption_phase', 'unique_id': '1234_lifetime_balanced_net_consumption_l2', @@ -14629,6 +14972,7 @@ 'original_name': 'Lifetime balanced net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption_phase', 'unique_id': '1234_lifetime_balanced_net_consumption_l3', @@ -14687,6 +15031,7 @@ 'original_name': 'Lifetime battery energy charged', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_battery_charged', 'unique_id': '1234_lifetime_battery_charged', @@ -14745,6 +15090,7 @@ 'original_name': 'Lifetime battery energy charged l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_battery_charged_phase', 'unique_id': '1234_lifetime_battery_charged_l1', @@ -14803,6 +15149,7 @@ 'original_name': 'Lifetime battery energy charged l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_battery_charged_phase', 'unique_id': '1234_lifetime_battery_charged_l2', @@ -14861,6 +15208,7 @@ 'original_name': 'Lifetime battery energy charged l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_battery_charged_phase', 'unique_id': '1234_lifetime_battery_charged_l3', @@ -14919,6 +15267,7 @@ 'original_name': 'Lifetime battery energy discharged', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_battery_discharged', 'unique_id': '1234_lifetime_battery_discharged', @@ -14977,6 +15326,7 @@ 'original_name': 'Lifetime battery energy discharged l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_battery_discharged_phase', 'unique_id': '1234_lifetime_battery_discharged_l1', @@ -15035,6 +15385,7 @@ 'original_name': 'Lifetime battery energy discharged l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_battery_discharged_phase', 'unique_id': '1234_lifetime_battery_discharged_l2', @@ -15093,6 +15444,7 @@ 'original_name': 'Lifetime battery energy discharged l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_battery_discharged_phase', 'unique_id': '1234_lifetime_battery_discharged_l3', @@ -15151,6 +15503,7 @@ 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption', 'unique_id': '1234_lifetime_consumption', @@ -15209,6 +15562,7 @@ 'original_name': 'Lifetime energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption_phase', 'unique_id': '1234_lifetime_consumption_l1', @@ -15267,6 +15621,7 @@ 'original_name': 'Lifetime energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption_phase', 'unique_id': '1234_lifetime_consumption_l2', @@ -15325,6 +15680,7 @@ 'original_name': 'Lifetime energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption_phase', 'unique_id': '1234_lifetime_consumption_l3', @@ -15383,6 +15739,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '1234_lifetime_production', @@ -15441,6 +15798,7 @@ 'original_name': 'Lifetime energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production_phase', 'unique_id': '1234_lifetime_production_l1', @@ -15499,6 +15857,7 @@ 'original_name': 'Lifetime energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production_phase', 'unique_id': '1234_lifetime_production_l2', @@ -15557,6 +15916,7 @@ 'original_name': 'Lifetime energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production_phase', 'unique_id': '1234_lifetime_production_l3', @@ -15615,6 +15975,7 @@ 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption', 'unique_id': '1234_lifetime_net_consumption', @@ -15673,6 +16034,7 @@ 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l1', @@ -15731,6 +16093,7 @@ 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l2', @@ -15789,6 +16152,7 @@ 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l3', @@ -15847,6 +16211,7 @@ 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production', 'unique_id': '1234_lifetime_net_production', @@ -15905,6 +16270,7 @@ 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l1', @@ -15963,6 +16329,7 @@ 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l2', @@ -16021,6 +16388,7 @@ 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l3', @@ -16071,6 +16439,7 @@ 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags', 'unique_id': '1234_net_consumption_ct_status_flags', @@ -16118,6 +16487,7 @@ 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l1', @@ -16165,6 +16535,7 @@ 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l2', @@ -16212,6 +16583,7 @@ 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l3', @@ -16259,6 +16631,7 @@ 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags', 'unique_id': '1234_production_ct_status_flags', @@ -16306,6 +16679,7 @@ 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l1', @@ -16353,6 +16727,7 @@ 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l2', @@ -16400,6 +16775,7 @@ 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l3', @@ -16447,6 +16823,7 @@ 'original_name': 'Meter status flags active storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_status_flags', 'unique_id': '1234_storage_ct_status_flags', @@ -16494,6 +16871,7 @@ 'original_name': 'Meter status flags active storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_status_flags_phase', 'unique_id': '1234_storage_ct_status_flags_l1', @@ -16541,6 +16919,7 @@ 'original_name': 'Meter status flags active storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_status_flags_phase', 'unique_id': '1234_storage_ct_status_flags_l2', @@ -16588,6 +16967,7 @@ 'original_name': 'Meter status flags active storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_status_flags_phase', 'unique_id': '1234_storage_ct_status_flags_l3', @@ -16641,6 +17021,7 @@ 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status', 'unique_id': '1234_net_consumption_ct_metering_status', @@ -16700,6 +17081,7 @@ 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l1', @@ -16759,6 +17141,7 @@ 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l2', @@ -16818,6 +17201,7 @@ 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l3', @@ -16877,6 +17261,7 @@ 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status', 'unique_id': '1234_production_ct_metering_status', @@ -16936,6 +17321,7 @@ 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l1', @@ -16995,6 +17381,7 @@ 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l2', @@ -17054,6 +17441,7 @@ 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l3', @@ -17113,6 +17501,7 @@ 'original_name': 'Metering status storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_metering_status', 'unique_id': '1234_storage_ct_metering_status', @@ -17172,6 +17561,7 @@ 'original_name': 'Metering status storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_metering_status_phase', 'unique_id': '1234_storage_ct_metering_status_l1', @@ -17231,6 +17621,7 @@ 'original_name': 'Metering status storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_metering_status_phase', 'unique_id': '1234_storage_ct_metering_status_l2', @@ -17290,6 +17681,7 @@ 'original_name': 'Metering status storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_metering_status_phase', 'unique_id': '1234_storage_ct_metering_status_l3', @@ -17351,6 +17743,7 @@ 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current', 'unique_id': '1234_net_ct_current', @@ -17409,6 +17802,7 @@ 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l1', @@ -17467,6 +17861,7 @@ 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l2', @@ -17525,6 +17920,7 @@ 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l3', @@ -17580,6 +17976,7 @@ 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor', 'unique_id': '1234_net_ct_powerfactor', @@ -17634,6 +18031,7 @@ 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l1', @@ -17688,6 +18086,7 @@ 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l2', @@ -17742,6 +18141,7 @@ 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l3', @@ -17796,6 +18196,7 @@ 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor', 'unique_id': '1234_production_ct_powerfactor', @@ -17850,6 +18251,7 @@ 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l1', @@ -17904,6 +18306,7 @@ 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l2', @@ -17958,6 +18361,7 @@ 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l3', @@ -18012,6 +18416,7 @@ 'original_name': 'Power factor storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_powerfactor', 'unique_id': '1234_storage_ct_powerfactor', @@ -18066,6 +18471,7 @@ 'original_name': 'Power factor storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_powerfactor_phase', 'unique_id': '1234_storage_ct_powerfactor_l1', @@ -18120,6 +18526,7 @@ 'original_name': 'Power factor storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_powerfactor_phase', 'unique_id': '1234_storage_ct_powerfactor_l2', @@ -18174,6 +18581,7 @@ 'original_name': 'Power factor storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_powerfactor_phase', 'unique_id': '1234_storage_ct_powerfactor_l3', @@ -18231,6 +18639,7 @@ 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current', 'unique_id': '1234_production_ct_current', @@ -18289,6 +18698,7 @@ 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l1', @@ -18347,6 +18757,7 @@ 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l2', @@ -18405,6 +18816,7 @@ 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l3', @@ -18449,12 +18861,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reserve_energy', 'unique_id': '1234_reserve_energy', @@ -18504,6 +18920,7 @@ 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reserve_soc', 'unique_id': '1234_reserve_soc', @@ -18561,6 +18978,7 @@ 'original_name': 'Storage CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_current', 'unique_id': '1234_storage_ct_current', @@ -18619,6 +19037,7 @@ 'original_name': 'Storage CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_current_phase', 'unique_id': '1234_storage_ct_current_l1', @@ -18677,6 +19096,7 @@ 'original_name': 'Storage CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_current_phase', 'unique_id': '1234_storage_ct_current_l2', @@ -18735,6 +19155,7 @@ 'original_name': 'Storage CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_current_phase', 'unique_id': '1234_storage_ct_current_l3', @@ -18793,6 +19214,7 @@ 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage', 'unique_id': '1234_voltage', @@ -18851,6 +19273,7 @@ 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l1', @@ -18909,6 +19332,7 @@ 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l2', @@ -18967,6 +19391,7 @@ 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l3', @@ -19025,6 +19450,7 @@ 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage', 'unique_id': '1234_production_ct_voltage', @@ -19083,6 +19509,7 @@ 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l1', @@ -19141,6 +19568,7 @@ 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l2', @@ -19199,6 +19627,7 @@ 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l3', @@ -19257,6 +19686,7 @@ 'original_name': 'Voltage storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_voltage', 'unique_id': '1234_storage_voltage', @@ -19315,6 +19745,7 @@ 'original_name': 'Voltage storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_voltage_phase', 'unique_id': '1234_storage_voltage_l1', @@ -19373,6 +19804,7 @@ 'original_name': 'Voltage storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_voltage_phase', 'unique_id': '1234_storage_voltage_l2', @@ -19431,6 +19863,7 @@ 'original_name': 'Voltage storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage_ct_voltage_phase', 'unique_id': '1234_storage_voltage_l3', @@ -19477,12 +19910,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -19533,6 +19970,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -19589,6 +20027,7 @@ 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption', 'unique_id': '1234_balanced_net_consumption', @@ -19647,6 +20086,7 @@ 'original_name': 'Balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption_phase', 'unique_id': '1234_balanced_net_consumption_l1', @@ -19705,6 +20145,7 @@ 'original_name': 'Balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption_phase', 'unique_id': '1234_balanced_net_consumption_l2', @@ -19763,6 +20204,7 @@ 'original_name': 'Balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption_phase', 'unique_id': '1234_balanced_net_consumption_l3', @@ -19821,6 +20263,7 @@ 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption', 'unique_id': '1234_net_consumption', @@ -19879,6 +20322,7 @@ 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l1', @@ -19937,6 +20381,7 @@ 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l2', @@ -19995,6 +20440,7 @@ 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_consumption_phase', 'unique_id': '1234_net_consumption_l3', @@ -20053,6 +20499,7 @@ 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption', 'unique_id': '1234_consumption', @@ -20111,6 +20558,7 @@ 'original_name': 'Current power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption_phase', 'unique_id': '1234_consumption_l1', @@ -20169,6 +20617,7 @@ 'original_name': 'Current power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption_phase', 'unique_id': '1234_consumption_l2', @@ -20227,6 +20676,7 @@ 'original_name': 'Current power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_consumption_phase', 'unique_id': '1234_consumption_l3', @@ -20285,6 +20735,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '1234_production', @@ -20343,6 +20794,7 @@ 'original_name': 'Current power production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production_phase', 'unique_id': '1234_production_l1', @@ -20401,6 +20853,7 @@ 'original_name': 'Current power production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production_phase', 'unique_id': '1234_production_l2', @@ -20459,6 +20912,7 @@ 'original_name': 'Current power production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production_phase', 'unique_id': '1234_production_l3', @@ -20515,6 +20969,7 @@ 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption', 'unique_id': '1234_seven_days_consumption', @@ -20570,6 +21025,7 @@ 'original_name': 'Energy consumption last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption_phase', 'unique_id': '1234_seven_days_consumption_l1', @@ -20625,6 +21081,7 @@ 'original_name': 'Energy consumption last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption_phase', 'unique_id': '1234_seven_days_consumption_l2', @@ -20680,6 +21137,7 @@ 'original_name': 'Energy consumption last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_consumption_phase', 'unique_id': '1234_seven_days_consumption_l3', @@ -20737,6 +21195,7 @@ 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption', 'unique_id': '1234_daily_consumption', @@ -20795,6 +21254,7 @@ 'original_name': 'Energy consumption today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption_phase', 'unique_id': '1234_daily_consumption_l1', @@ -20853,6 +21313,7 @@ 'original_name': 'Energy consumption today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption_phase', 'unique_id': '1234_daily_consumption_l2', @@ -20911,6 +21372,7 @@ 'original_name': 'Energy consumption today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_consumption_phase', 'unique_id': '1234_daily_consumption_l3', @@ -20967,6 +21429,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '1234_seven_days_production', @@ -21022,6 +21485,7 @@ 'original_name': 'Energy production last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production_phase', 'unique_id': '1234_seven_days_production_l1', @@ -21077,6 +21541,7 @@ 'original_name': 'Energy production last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production_phase', 'unique_id': '1234_seven_days_production_l2', @@ -21132,6 +21597,7 @@ 'original_name': 'Energy production last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production_phase', 'unique_id': '1234_seven_days_production_l3', @@ -21189,6 +21655,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '1234_daily_production', @@ -21247,6 +21714,7 @@ 'original_name': 'Energy production today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production_phase', 'unique_id': '1234_daily_production_l1', @@ -21305,6 +21773,7 @@ 'original_name': 'Energy production today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production_phase', 'unique_id': '1234_daily_production_l2', @@ -21363,6 +21832,7 @@ 'original_name': 'Energy production today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production_phase', 'unique_id': '1234_daily_production_l3', @@ -21418,6 +21888,7 @@ 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency', 'unique_id': '1234_frequency', @@ -21473,6 +21944,7 @@ 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l1', @@ -21528,6 +22000,7 @@ 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l2', @@ -21583,6 +22056,7 @@ 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_frequency_phase', 'unique_id': '1234_frequency_l3', @@ -21638,6 +22112,7 @@ 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency', 'unique_id': '1234_production_ct_frequency', @@ -21693,6 +22168,7 @@ 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l1', @@ -21748,6 +22224,7 @@ 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l2', @@ -21803,6 +22280,7 @@ 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency_phase', 'unique_id': '1234_production_ct_frequency_l3', @@ -21861,6 +22339,7 @@ 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption', 'unique_id': '1234_lifetime_balanced_net_consumption', @@ -21919,6 +22398,7 @@ 'original_name': 'Lifetime balanced net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption_phase', 'unique_id': '1234_lifetime_balanced_net_consumption_l1', @@ -21977,6 +22457,7 @@ 'original_name': 'Lifetime balanced net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption_phase', 'unique_id': '1234_lifetime_balanced_net_consumption_l2', @@ -22035,6 +22516,7 @@ 'original_name': 'Lifetime balanced net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption_phase', 'unique_id': '1234_lifetime_balanced_net_consumption_l3', @@ -22093,6 +22575,7 @@ 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption', 'unique_id': '1234_lifetime_consumption', @@ -22151,6 +22634,7 @@ 'original_name': 'Lifetime energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption_phase', 'unique_id': '1234_lifetime_consumption_l1', @@ -22209,6 +22693,7 @@ 'original_name': 'Lifetime energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption_phase', 'unique_id': '1234_lifetime_consumption_l2', @@ -22267,6 +22752,7 @@ 'original_name': 'Lifetime energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_consumption_phase', 'unique_id': '1234_lifetime_consumption_l3', @@ -22325,6 +22811,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '1234_lifetime_production', @@ -22383,6 +22870,7 @@ 'original_name': 'Lifetime energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production_phase', 'unique_id': '1234_lifetime_production_l1', @@ -22441,6 +22929,7 @@ 'original_name': 'Lifetime energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production_phase', 'unique_id': '1234_lifetime_production_l2', @@ -22499,6 +22988,7 @@ 'original_name': 'Lifetime energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production_phase', 'unique_id': '1234_lifetime_production_l3', @@ -22557,6 +23047,7 @@ 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption', 'unique_id': '1234_lifetime_net_consumption', @@ -22615,6 +23106,7 @@ 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l1', @@ -22673,6 +23165,7 @@ 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l2', @@ -22731,6 +23224,7 @@ 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_consumption_phase', 'unique_id': '1234_lifetime_net_consumption_l3', @@ -22789,6 +23283,7 @@ 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production', 'unique_id': '1234_lifetime_net_production', @@ -22847,6 +23342,7 @@ 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l1', @@ -22905,6 +23401,7 @@ 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l2', @@ -22963,6 +23460,7 @@ 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_net_production_phase', 'unique_id': '1234_lifetime_net_production_l3', @@ -23013,6 +23511,7 @@ 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags', 'unique_id': '1234_net_consumption_ct_status_flags', @@ -23060,6 +23559,7 @@ 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l1', @@ -23107,6 +23607,7 @@ 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l2', @@ -23154,6 +23655,7 @@ 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_status_flags_phase', 'unique_id': '1234_net_consumption_ct_status_flags_l3', @@ -23201,6 +23703,7 @@ 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags', 'unique_id': '1234_production_ct_status_flags', @@ -23248,6 +23751,7 @@ 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l1', @@ -23295,6 +23799,7 @@ 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l2', @@ -23342,6 +23847,7 @@ 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags_phase', 'unique_id': '1234_production_ct_status_flags_l3', @@ -23395,6 +23901,7 @@ 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status', 'unique_id': '1234_net_consumption_ct_metering_status', @@ -23454,6 +23961,7 @@ 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l1', @@ -23513,6 +24021,7 @@ 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l2', @@ -23572,6 +24081,7 @@ 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_metering_status_phase', 'unique_id': '1234_net_consumption_ct_metering_status_l3', @@ -23631,6 +24141,7 @@ 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status', 'unique_id': '1234_production_ct_metering_status', @@ -23690,6 +24201,7 @@ 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l1', @@ -23749,6 +24261,7 @@ 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l2', @@ -23808,6 +24321,7 @@ 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status_phase', 'unique_id': '1234_production_ct_metering_status_l3', @@ -23869,6 +24383,7 @@ 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current', 'unique_id': '1234_net_ct_current', @@ -23927,6 +24442,7 @@ 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l1', @@ -23985,6 +24501,7 @@ 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l2', @@ -24043,6 +24560,7 @@ 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_current_phase', 'unique_id': '1234_net_ct_current_l3', @@ -24098,6 +24616,7 @@ 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor', 'unique_id': '1234_net_ct_powerfactor', @@ -24152,6 +24671,7 @@ 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l1', @@ -24206,6 +24726,7 @@ 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l2', @@ -24260,6 +24781,7 @@ 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_powerfactor_phase', 'unique_id': '1234_net_ct_powerfactor_l3', @@ -24314,6 +24836,7 @@ 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor', 'unique_id': '1234_production_ct_powerfactor', @@ -24368,6 +24891,7 @@ 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l1', @@ -24422,6 +24946,7 @@ 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l2', @@ -24476,6 +25001,7 @@ 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor_phase', 'unique_id': '1234_production_ct_powerfactor_l3', @@ -24533,6 +25059,7 @@ 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current', 'unique_id': '1234_production_ct_current', @@ -24591,6 +25118,7 @@ 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l1', @@ -24649,6 +25177,7 @@ 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l2', @@ -24707,6 +25236,7 @@ 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current_phase', 'unique_id': '1234_production_ct_current_l3', @@ -24765,6 +25295,7 @@ 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage', 'unique_id': '1234_voltage', @@ -24823,6 +25354,7 @@ 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l1', @@ -24881,6 +25413,7 @@ 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l2', @@ -24939,6 +25472,7 @@ 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_ct_voltage_phase', 'unique_id': '1234_voltage_l3', @@ -24997,6 +25531,7 @@ 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage', 'unique_id': '1234_production_ct_voltage', @@ -25055,6 +25590,7 @@ 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l1', @@ -25113,6 +25649,7 @@ 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l2', @@ -25171,6 +25708,7 @@ 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage_phase', 'unique_id': '1234_production_ct_voltage_l3', @@ -25217,12 +25755,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -25273,6 +25815,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', @@ -25329,6 +25872,7 @@ 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balanced_net_consumption', 'unique_id': '1234_balanced_net_consumption', @@ -25387,6 +25931,7 @@ 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power_production', 'unique_id': '1234_production', @@ -25443,6 +25988,7 @@ 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seven_days_production', 'unique_id': '1234_seven_days_production', @@ -25500,6 +26046,7 @@ 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_production', 'unique_id': '1234_daily_production', @@ -25555,6 +26102,7 @@ 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_frequency', 'unique_id': '1234_production_ct_frequency', @@ -25613,6 +26161,7 @@ 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_balanced_net_consumption', 'unique_id': '1234_lifetime_balanced_net_consumption', @@ -25671,6 +26220,7 @@ 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_production', 'unique_id': '1234_lifetime_production', @@ -25721,6 +26271,7 @@ 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_status_flags', 'unique_id': '1234_production_ct_status_flags', @@ -25774,6 +26325,7 @@ 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_metering_status', 'unique_id': '1234_production_ct_metering_status', @@ -25832,6 +26384,7 @@ 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_powerfactor', 'unique_id': '1234_production_ct_powerfactor', @@ -25889,6 +26442,7 @@ 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_current', 'unique_id': '1234_production_ct_current', @@ -25947,6 +26501,7 @@ 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'production_ct_voltage', 'unique_id': '1234_production_ct_voltage', @@ -25993,12 +26548,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1', @@ -26049,6 +26608,7 @@ 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_reported', 'unique_id': '1_last_reported', diff --git a/tests/components/enphase_envoy/snapshots/test_switch.ambr b/tests/components/enphase_envoy/snapshots/test_switch.ambr index 77b682cb948..2a00e46b6af 100644 --- a/tests/components/enphase_envoy/snapshots/test_switch.ambr +++ b/tests/components/enphase_envoy/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge from grid', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_from_grid', 'unique_id': '1234_charge_from_grid', @@ -74,6 +75,7 @@ 'original_name': 'Charge from grid', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_from_grid', 'unique_id': '654321_charge_from_grid', @@ -121,6 +123,7 @@ 'original_name': 'Grid enabled', 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_enabled', 'unique_id': '654321_mains_admin_state', @@ -168,6 +171,7 @@ 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC1_relay_status', @@ -215,6 +219,7 @@ 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC2_relay_status', @@ -262,6 +267,7 @@ 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC3_relay_status', diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index ef071b421fe..560d0719424 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -510,7 +510,6 @@ async def test_coordinator_interface_information_no_device( ) # update device to force no device found in mac verification - device_registry = dr.async_get(hass) envoy_device = device_registry.async_get_device( identifiers={ ( @@ -531,3 +530,60 @@ async def test_coordinator_interface_information_no_device( # verify no device found message in log assert "No envoy device found in device registry" in caplog.text + + +@respx.mock +async def test_coordinator_interface_information_mac_also_in_other_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, +) -> None: + """Test coordinator interface mac verification with MAC also in other existing device.""" + await setup_integration(hass, config_entry) + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + # add existing device with MAC and sparsely populated i.e. unifi that found envoy + other_config_entry = MockConfigEntry(domain="test", data={}) + other_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=other_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:55")}, + manufacturer="Enphase Energy", + ) + + envoy_device = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + mock_envoy.serial_number, + ) + } + ) + assert envoy_device + + # 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 mac was added + assert "added connection: ('mac', '00:11:22:33:44:55') to Envoy 1234" in caplog.text + + # verify connection is now in envoy device + envoy_device_refetched = device_registry.async_get(envoy_device.id) + assert envoy_device_refetched + assert envoy_device_refetched.name == "Envoy 1234" + assert envoy_device_refetched.serial_number == "1234" + assert envoy_device_refetched.connections == { + ( + dr.CONNECTION_NETWORK_MAC, + "00:11:22:33:44:55", + ) + } diff --git a/tests/components/environment_canada/test_diagnostics.py b/tests/components/environment_canada/test_diagnostics.py index 7c35c33f93a..f46b89d20c2 100644 --- a/tests/components/environment_canada/test_diagnostics.py +++ b/tests/components/environment_canada/test_diagnostics.py @@ -2,7 +2,7 @@ from typing import Any -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.environment_canada.const import CONF_STATION from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE diff --git a/tests/components/esphome/common.py b/tests/components/esphome/common.py index 426eee11341..814fa27215b 100644 --- a/tests/components/esphome/common.py +++ b/tests/components/esphome/common.py @@ -4,8 +4,6 @@ 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 diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 739c2119bf0..dd42ee97029 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -14,7 +14,7 @@ from aioesphomeapi import ( ClimateSwingMode, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 250cc8dbc49..70acf327788 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -5,7 +5,7 @@ from unittest.mock import ANY from aioesphomeapi import APIClient import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components import bluetooth @@ -95,6 +95,7 @@ async def test_diagnostics_with_bluetooth( "scanning": True, "source": "AA:BB:CC:DD:EE:FC", "start_time": ANY, + "raw_advertisement_data": {}, "time_since_last_device_detection": {}, "type": "ESPHomeScanner", }, diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index a56ec1caeba..05a95fe0e00 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -148,7 +148,7 @@ async def test_fan_entity_with_all_features_new_api( key=1, name="my fan", unique_id="my_fan", - supported_speed_levels=4, + supported_speed_count=4, supports_direction=True, supports_speed=True, supports_oscillation=True, diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index d3302cab75c..0cf3e10f11e 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -5,6 +5,7 @@ from unittest.mock import call from aioesphomeapi import ( APIClient, APIVersion, + ColorMode as ESPColorMode, LightColorCapability, LightInfo, LightState, @@ -58,7 +59,7 @@ async def test_light_on_off( unique_id="my_light", min_mireds=153, max_mireds=400, - supported_color_modes=[LightColorCapability.ON_OFF], + supported_color_modes=[ESPColorMode.ON_OFF], ) ] states = [LightState(key=1, state=True)] @@ -203,6 +204,55 @@ async def test_light_brightness( mock_client.light_command.reset_mock() +async def test_light_legacy_brightness( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a generic light entity that only supports legacy brightness.""" + mock_client.api_version = APIVersion(1, 7) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[LightColorCapability.BRIGHTNESS, 2], + ) + ] + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=ESPColorMode.LEGACY_BRIGHTNESS + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.BRIGHTNESS, + ] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [call(key=1, state=True, color_mode=LightColorCapability.BRIGHTNESS)] + ) + mock_client.light_command.reset_mock() + + async def test_light_brightness_on_off( hass: HomeAssistant, mock_client: APIClient, @@ -218,12 +268,14 @@ async def test_light_brightness_on_off( unique_id="my_light", min_mireds=153, max_mireds=400, - supported_color_modes=[ - LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS - ], + supported_color_modes=[ESPColorMode.ON_OFF, ESPColorMode.BRIGHTNESS], + ) + ] + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=ESPColorMode.BRIGHTNESS ) ] - states = [LightState(key=1, state=True, brightness=100)] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -234,6 +286,10 @@ async def test_light_brightness_on_off( state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.BRIGHTNESS, + ] + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.BRIGHTNESS await hass.services.async_call( LIGHT_DOMAIN, @@ -246,8 +302,7 @@ async def test_light_brightness_on_off( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS.value, ) ] ) @@ -264,8 +319,7 @@ async def test_light_brightness_on_off( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS.value, brightness=pytest.approx(0.4980392156862745), ) ] @@ -407,13 +461,18 @@ 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 - | LIGHT_COLOR_CAPABILITY_UNKNOWN + ESPColorMode.ON_OFF, + ESPColorMode.BRIGHTNESS, + LIGHT_COLOR_CAPABILITY_UNKNOWN, ], ) ] - states = [LightState(key=1, state=True, brightness=100)] + entity_info[0].supported_color_modes.append(LIGHT_COLOR_CAPABILITY_UNKNOWN) + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=LIGHT_COLOR_CAPABILITY_UNKNOWN + ) + ] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -436,9 +495,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | LIGHT_COLOR_CAPABILITY_UNKNOWN, + color_mode=LIGHT_COLOR_CAPABILITY_UNKNOWN, ) ] ) @@ -455,9 +512,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | LIGHT_COLOR_CAPABILITY_UNKNOWN, + color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), ) ] @@ -517,13 +572,10 @@ async def test_rgb_color_temp_light( ) -> None: """Test a generic light that supports color temp and RGB.""" color_modes = [ - LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, - LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | LightColorCapability.COLOR_TEMPERATURE, - LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | LightColorCapability.RGB, + ESPColorMode.ON_OFF, + ESPColorMode.BRIGHTNESS, + ESPColorMode.COLOR_TEMPERATURE, + ESPColorMode.RGB, ] mock_client.api_version = APIVersion(1, 7) @@ -538,7 +590,11 @@ async def test_rgb_color_temp_light( supported_color_modes=color_modes, ) ] - states = [LightState(key=1, state=True, brightness=100)] + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=ESPColorMode.BRIGHTNESS + ) + ] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -561,8 +617,7 @@ async def test_rgb_color_temp_light( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS, ) ] ) @@ -579,8 +634,7 @@ async def test_rgb_color_temp_light( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), ) ] @@ -598,9 +652,7 @@ async def test_rgb_color_temp_light( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | LightColorCapability.COLOR_TEMPERATURE, + color_mode=ESPColorMode.COLOR_TEMPERATURE, color_temperature=400, ) ] @@ -908,12 +960,14 @@ async def test_light_rgbww_with_cold_warm_white_support( min_mireds=153, max_mireds=400, supported_color_modes=[ - LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS + ESPColorMode.RGB, + ESPColorMode.WHITE, + ESPColorMode.COLOR_TEMPERATURE, + ESPColorMode.COLD_WARM_WHITE, + ESPColorMode.ON_OFF, + ESPColorMode.BRIGHTNESS, + ESPColorMode.RGB_COLD_WARM_WHITE, + ESPColorMode.RGB_WHITE, ], ) ] @@ -928,12 +982,7 @@ async def test_light_rgbww_with_cold_warm_white_support( blue=1, warm_white=1, cold_white=1, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, ) ] user_service = [] @@ -946,7 +995,13 @@ async def test_light_rgbww_with_cold_warm_white_support( state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBWW] + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.WHITE, + ] assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGBWW assert state.attributes[ATTR_RGBWW_COLOR] == (255, 255, 255, 255, 255) @@ -961,12 +1016,7 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, ) ] ) @@ -983,12 +1033,7 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, brightness=pytest.approx(0.4980392156862745), ) ] @@ -1011,14 +1056,7 @@ async def test_light_rgbww_with_cold_warm_white_support( key=1, state=True, color_brightness=1.0, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, - cold_white=0, - warm_white=0, + color_mode=ESPColorMode.RGB, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), ) @@ -1037,16 +1075,9 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_brightness=pytest.approx(0.4235294117647059), - cold_white=1, - warm_white=1, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, - rgb=(0, pytest.approx(0.5462962962962963), 1.0), + color_brightness=1.0, + color_mode=ESPColorMode.RGB, + rgb=(1.0, 1.0, 1.0), ) ] ) @@ -1063,16 +1094,10 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_brightness=pytest.approx(0.4235294117647059), - cold_white=1, - warm_white=1, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, - rgb=(0, pytest.approx(0.5462962962962963), 1.0), + color_brightness=1.0, + white=1, + color_mode=ESPColorMode.RGB_WHITE, + rgb=(1.0, 1.0, 1.0), ) ] ) @@ -1095,12 +1120,7 @@ async def test_light_rgbww_with_cold_warm_white_support( color_brightness=1, cold_white=1, warm_white=1, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, rgb=(1, 1, 1), ) ] @@ -1118,16 +1138,8 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_brightness=0, - cold_white=0, - warm_white=100, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, - rgb=(0, 0, 0), + color_temperature=400.0, + color_mode=ESPColorMode.COLOR_TEMPERATURE, ) ] ) @@ -1733,11 +1745,16 @@ async def test_light_effects( max_mireds=400, effects=["effect1", "effect2"], supported_color_modes=[ - LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + ESPColorMode.ON_OFF, + ESPColorMode.BRIGHTNESS, ], ) ] - states = [LightState(key=1, state=True, brightness=100)] + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=ESPColorMode.BRIGHTNESS + ) + ] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -1761,8 +1778,7 @@ async def test_light_effects( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS, effect="effect1", ) ] diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 18a997dc09a..3f1e5e99c34 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -430,3 +430,105 @@ async def test_media_player_proxy( mock_async_create_proxy_url.assert_not_called() media_args = mock_client.media_player_command.call_args.kwargs assert media_args["media_url"] == media_url + + +async def test_media_player_formats_reload_preserves_data( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that media player formats are properly managed on reload.""" + # Create a media player with supported formats + supported_formats = [ + MediaPlayerSupportedFormat( + format="mp3", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.DEFAULT, + ), + MediaPlayerSupportedFormat( + format="wav", + sample_rate=16000, + num_channels=1, + purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + sample_bytes=2, + ), + ] + + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[ + MediaPlayerInfo( + object_id="test_media_player", + key=1, + name="Test Media Player", + unique_id="test_unique_id", + supports_pause=True, + supported_formats=supported_formats, + ) + ], + states=[ + MediaPlayerEntityState( + key=1, volume=50, muted=False, state=MediaPlayerState.IDLE + ) + ], + ) + await hass.async_block_till_done() + + # Verify entity was created + state = hass.states.get("media_player.test_test_media_player") + assert state is not None + assert state.state == "idle" + + # Test that play_media works with proxy URL (which requires formats to be stored) + media_url = "http://127.0.0.1/test.mp3" + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_test_media_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: media_url, + }, + blocking=True, + ) + + # Verify the API was called with a proxy URL (contains /api/esphome/ffmpeg_proxy/) + mock_client.media_player_command.assert_called_once() + call_args = mock_client.media_player_command.call_args + assert "/api/esphome/ffmpeg_proxy/" in call_args.kwargs["media_url"] + assert ".mp3" in call_args.kwargs["media_url"] # Should use mp3 format for default + assert call_args.kwargs["announcement"] is None + + mock_client.media_player_command.reset_mock() + + # Reload the integration + await hass.config_entries.async_reload(mock_device.entry.entry_id) + await hass.async_block_till_done() + + # Verify entity still exists after reload + state = hass.states.get("media_player.test_test_media_player") + assert state is not None + + # Test that play_media still works after reload with announcement + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_test_media_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: media_url, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + + # Verify the API was called with a proxy URL using wav format for announcements + mock_client.media_player_command.assert_called_once() + call_args = mock_client.media_player_command.call_args + assert "/api/esphome/ffmpeg_proxy/" in call_args.kwargs["media_url"] + assert ( + ".wav" in call_args.kwargs["media_url"] + ) # Should use wav format for announcement + assert call_args.kwargs["announcement"] is True diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index 268b30f8b52..692a7dd9cc9 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -70,8 +70,7 @@ async def test_device_conflict_manual( 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) + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None await async_process_repairs_platforms(hass) client = await hass_client() @@ -182,8 +181,7 @@ async def test_device_conflict_migration( 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) + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None await async_process_repairs_platforms(hass) client = await hass_client() diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 0c443dc5941..6763d2ab9a9 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -203,7 +203,7 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( key=1, name="my sensor", unique_id="my_sensor", - last_reset_type=LastResetType.AUTO, + legacy_last_reset_type=LastResetType.AUTO, state_class=ESPHomeSensorStateClass.MEASUREMENT, ) ] diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index c9b88d9fb57..a612f44c07f 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -1,5 +1,6 @@ """Test ESPHome update entities.""" +import asyncio from typing import Any from unittest.mock import patch @@ -12,6 +13,8 @@ from homeassistant.components.homeassistant import ( SERVICE_UPDATE_ENTITY, ) from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_UPDATE_PERCENTAGE, DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, UpdateEntityFeature, @@ -28,6 +31,12 @@ from homeassistant.exceptions import HomeAssistantError from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType +from tests.typing import WebSocketGenerator + +RELEASE_SUMMARY = "This is a release summary" +RELEASE_URL = "https://esphome.io/changelog" +ENTITY_ID = "update.test_myupdate" + @pytest.fixture(autouse=True) def enable_entity(entity_registry_enabled_by_default: None) -> None: @@ -460,8 +469,8 @@ async def test_generic_device_update_entity( current_version="2024.6.0", latest_version="2024.6.0", title="ESPHome Project", - release_summary="This is a release summary", - release_url="https://esphome.io/changelog", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, ) ] user_service = [] @@ -471,7 +480,7 @@ async def test_generic_device_update_entity( user_service=user_service, states=states, ) - state = hass.states.get("update.test_myupdate") + state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_OFF @@ -496,8 +505,8 @@ async def test_generic_device_update_entity_has_update( current_version="2024.6.0", latest_version="2024.6.1", title="ESPHome Project", - release_summary="This is a release summary", - release_url="https://esphome.io/changelog", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, ) ] user_service = [] @@ -507,14 +516,14 @@ async def test_generic_device_update_entity_has_update( user_service=user_service, states=states, ) - state = hass.states.get("update.test_myupdate") + state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_ON await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_myupdate"}, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) @@ -527,22 +536,417 @@ async def test_generic_device_update_entity_has_update( current_version="2024.6.0", latest_version="2024.6.1", title="ESPHome Project", - release_summary="This is a release summary", - release_url="https://esphome.io/changelog", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, ) ) - state = hass.states.get("update.test_myupdate") + state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_ON - assert state.attributes["in_progress"] is True - assert state.attributes["update_percentage"] == 50 - + assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 50 await hass.services.async_call( HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "update.test_myupdate"}, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) + mock_device.set_state( + UpdateState( + key=1, + in_progress=True, + has_progress=False, + current_version="2024.6.0", + latest_version="2024.6.1", + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None + mock_client.update_command.assert_called_with(key=1, command=UpdateCommand.CHECK) + + +async def test_update_entity_release_notes( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test ESPHome update entity release notes.""" + entity_info = [ + UpdateInfo( + object_id="myupdate", + key=1, + name="my update", + unique_id="my_update", + ) + ] + + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=[], + ) + + # release notes + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": ENTITY_ID, + } + ) + + result = await client.receive_json() + assert result["result"] is None + + mock_device.set_state( + UpdateState( + key=1, + current_version="2024.6.0", + latest_version="2024.6.1", + title="ESPHome Project", + release_summary="", + release_url=RELEASE_URL, + ) + ) + + await client.send_json( + { + "id": 2, + "type": "update/release_notes", + "entity_id": ENTITY_ID, + } + ) + + result = await client.receive_json() + assert result["result"] is None + + mock_device.set_state( + UpdateState( + key=1, + current_version="2024.6.0", + latest_version="2024.6.1", + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ) + + await client.send_json( + { + "id": 3, + "type": "update/release_notes", + "entity_id": ENTITY_ID, + } + ) + + result = await client.receive_json() + assert result["result"] == RELEASE_SUMMARY + + +async def test_attempt_to_update_twice( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test attempting to update twice.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + + async def delayed_compile(*args: Any, **kwargs: Any) -> None: + """Delay the update.""" + await asyncio.sleep(0) + return True + + # Compile success, upload fails + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + delayed_compile, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=False, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + + with pytest.raises(HomeAssistantError, match="update is already in progress"): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="OTA"): + await update_task + + +async def test_update_deep_sleep_already_online( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test attempting to update twice.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={"has_deep_sleep": True}, + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + + # Compile success, upload success + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + + +async def test_update_deep_sleep_offline( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test device comes online while updating.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={"has_deep_sleep": True}, + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + await device.mock_disconnect(True) + + # Compile success, upload success + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + await asyncio.sleep(0) + assert not update_task.done() + await device.mock_connect() + await hass.async_block_till_done() + + +async def test_update_deep_sleep_offline_sleep_during_ota( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test device goes to sleep right as we start the OTA.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={"has_deep_sleep": True}, + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + await device.mock_disconnect(True) + + upload_attempt = 0 + upload_attempt_2_future = hass.loop.create_future() + disconnect_future = hass.loop.create_future() + + async def upload_takes_a_while(*args: Any, **kwargs: Any) -> None: + """Delay the update.""" + nonlocal upload_attempt + upload_attempt += 1 + if upload_attempt == 1: + # We are simulating the device going back to sleep + # before the upload can be started + # Wait for the device to go unavailable + # before returning false + await disconnect_future + return False + upload_attempt_2_future.set_result(None) + return True + + # Compile success, upload fails first time, success second time + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + upload_takes_a_while, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + await asyncio.sleep(0) + assert not update_task.done() + await device.mock_connect() + # Mock device being at the end of its sleep cycle + # and going to sleep right as the upload starts + # This can happen because there is non zero time + # between when we tell the dashboard to upload and + # when the upload actually starts + await device.mock_disconnect(True) + disconnect_future.set_result(None) + assert not upload_attempt_2_future.done() + # Now the device wakes up and the upload is attempted + await device.mock_connect() + await upload_attempt_2_future + await hass.async_block_till_done() + + +async def test_update_deep_sleep_offline_cancelled_unload( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test deep sleep update attempt is cancelled on unload.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={"has_deep_sleep": True}, + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + await device.mock_disconnect(True) + + # Compile success, upload success, but we cancel the update + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + await asyncio.sleep(0) + assert not update_task.done() + await hass.config_entries.async_unload(device.entry.entry_id) + await hass.async_block_till_done() + assert update_task.cancelled() diff --git a/tests/components/evohome/test_climate.py b/tests/components/evohome/test_climate.py index b1b930c6382..171b910690b 100644 --- a/tests/components/evohome/test_climate.py +++ b/tests/components/evohome/test_climate.py @@ -9,7 +9,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_HVAC_MODE, diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index ca9a5ba6af8..c06f57b61ed 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -10,7 +10,7 @@ from unittest.mock import patch from evohomeasync2 import EvohomeClient from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.water_heater import ( ATTR_AWAY_MODE, diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index ff538b31edb..20d70902e83 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from pyezviz.exceptions import ( +from pyezvizapi.exceptions import ( EzvizAuthVerificationCode, InvalidHost, InvalidURL, diff --git a/tests/components/fastdotcom/test_diagnostics.py b/tests/components/fastdotcom/test_diagnostics.py index 7ea644665c7..36b29c8a9f1 100644 --- a/tests/components/fastdotcom/test_diagnostics.py +++ b/tests/components/fastdotcom/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 53cecd78bb6..fde92faa673 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -255,6 +255,8 @@ def mock_button_device() -> Mock: climate.central_scene_event = [SceneEvent(1, "Pressed")] climate.actions = {} climate.interfaces = ["zwaveCentralScene"] + climate.battery_level = 100 + climate.armed = False return climate diff --git a/tests/components/fibaro/test_diagnostics.py b/tests/components/fibaro/test_diagnostics.py index c6148e0cc33..35b75a79ba9 100644 --- a/tests/components/fibaro/test_diagnostics.py +++ b/tests/components/fibaro/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import Mock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fibaro import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/filesize/snapshots/test_sensor.ambr b/tests/components/filesize/snapshots/test_sensor.ambr index e7f6f9d042b..10eaa915616 100644 --- a/tests/components/filesize/snapshots/test_sensor.ambr +++ b/tests/components/filesize/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Created', 'platform': 'filesize', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'created', 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X-created', @@ -75,6 +76,7 @@ 'original_name': 'Last updated', 'platform': 'filesize', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_updated', 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X-last_updated', @@ -119,12 +121,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Size', 'platform': 'filesize', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'size', 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X', @@ -171,12 +177,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Size in bytes', 'platform': 'filesize', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'size_bytes', 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X-bytes', diff --git a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr index 0b45e1f19be..d8408a63aa6 100644 --- a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Air filter polluted', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_filter_polluted', 'unique_id': '0000-0001-air_filter_polluted', diff --git a/tests/components/flexit_bacnet/snapshots/test_climate.ambr b/tests/components/flexit_bacnet/snapshots/test_climate.ambr index d15fc291a16..a58927be917 100644 --- a/tests/components/flexit_bacnet/snapshots/test_climate.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_climate.ambr @@ -40,6 +40,7 @@ 'original_name': None, 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '0000-0001', diff --git a/tests/components/flexit_bacnet/snapshots/test_number.ambr b/tests/components/flexit_bacnet/snapshots/test_number.ambr index 622ec81e45d..6a307a9b463 100644 --- a/tests/components/flexit_bacnet/snapshots/test_number.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Away extract fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'away_extract_fan_setpoint', 'unique_id': '0000-0001-away_extract_fan_setpoint', @@ -90,6 +91,7 @@ 'original_name': 'Away supply fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'away_supply_fan_setpoint', 'unique_id': '0000-0001-away_supply_fan_setpoint', @@ -148,6 +150,7 @@ 'original_name': 'Cooker hood extract fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cooker_hood_extract_fan_setpoint', 'unique_id': '0000-0001-cooker_hood_extract_fan_setpoint', @@ -206,6 +209,7 @@ 'original_name': 'Cooker hood supply fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cooker_hood_supply_fan_setpoint', 'unique_id': '0000-0001-cooker_hood_supply_fan_setpoint', @@ -264,6 +268,7 @@ 'original_name': 'Fireplace extract fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fireplace_extract_fan_setpoint', 'unique_id': '0000-0001-fireplace_extract_fan_setpoint', @@ -322,6 +327,7 @@ 'original_name': 'Fireplace mode runtime', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fireplace_mode_runtime', 'unique_id': '0000-0001-fireplace_mode_runtime', @@ -380,6 +386,7 @@ 'original_name': 'Fireplace supply fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fireplace_supply_fan_setpoint', 'unique_id': '0000-0001-fireplace_supply_fan_setpoint', @@ -438,6 +445,7 @@ 'original_name': 'High extract fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'high_extract_fan_setpoint', 'unique_id': '0000-0001-high_extract_fan_setpoint', @@ -496,6 +504,7 @@ 'original_name': 'High supply fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'high_supply_fan_setpoint', 'unique_id': '0000-0001-high_supply_fan_setpoint', @@ -554,6 +563,7 @@ 'original_name': 'Home extract fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'home_extract_fan_setpoint', 'unique_id': '0000-0001-home_extract_fan_setpoint', @@ -612,6 +622,7 @@ 'original_name': 'Home supply fan setpoint', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'home_supply_fan_setpoint', 'unique_id': '0000-0001-home_supply_fan_setpoint', diff --git a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr index b265a4402dc..c3c3b8f185d 100644 --- a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr @@ -32,6 +32,7 @@ 'original_name': 'Air filter operating time', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_filter_operating_time', 'unique_id': '0000-0001-air_filter_operating_time', @@ -84,6 +85,7 @@ 'original_name': 'Electric heater power', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'electric_heater_power', 'unique_id': '0000-0001-electric_heater_power', @@ -135,6 +137,7 @@ 'original_name': 'Exhaust air fan', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'exhaust_air_fan_rpm', 'unique_id': '0000-0001-exhaust_air_fan_rpm', @@ -186,6 +189,7 @@ 'original_name': 'Exhaust air fan control signal', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'exhaust_air_fan_control_signal', 'unique_id': '0000-0001-exhaust_air_fan_control_signal', @@ -229,12 +233,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Exhaust air temperature', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'exhaust_air_temperature', 'unique_id': '0000-0001-exhaust_air_temperature', @@ -278,12 +286,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Extract air temperature', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'extract_air_temperature', 'unique_id': '0000-0001-extract_air_temperature', @@ -338,6 +350,7 @@ 'original_name': 'Fireplace ventilation remaining duration', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fireplace_ventilation_remaining_duration', 'unique_id': '0000-0001-fireplace_ventilation_remaining_duration', @@ -390,6 +403,7 @@ 'original_name': 'Heat exchanger efficiency', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heat_exchanger_efficiency', 'unique_id': '0000-0001-heat_exchanger_efficiency', @@ -441,6 +455,7 @@ 'original_name': 'Heat exchanger speed', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heat_exchanger_speed', 'unique_id': '0000-0001-heat_exchanger_speed', @@ -484,12 +499,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside air temperature', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_air_temperature', 'unique_id': '0000-0001-outside_air_temperature', @@ -544,6 +563,7 @@ 'original_name': 'Rapid ventilation remaining duration', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rapid_ventilation_remaining_duration', 'unique_id': '0000-0001-rapid_ventilation_remaining_duration', @@ -588,12 +608,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Room temperature', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'room_temperature', 'unique_id': '0000-0001-room_temperature', @@ -645,6 +669,7 @@ 'original_name': 'Supply air fan', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supply_air_fan_rpm', 'unique_id': '0000-0001-supply_air_fan_rpm', @@ -696,6 +721,7 @@ 'original_name': 'Supply air fan control signal', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supply_air_fan_control_signal', 'unique_id': '0000-0001-supply_air_fan_control_signal', @@ -739,12 +765,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Supply air temperature', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supply_air_temperature', 'unique_id': '0000-0001-supply_air_temperature', diff --git a/tests/components/flexit_bacnet/snapshots/test_switch.ambr b/tests/components/flexit_bacnet/snapshots/test_switch.ambr index 0e27c2e938a..6ac6f904758 100644 --- a/tests/components/flexit_bacnet/snapshots/test_switch.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Cooker hood mode', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cooker_hood_mode', 'unique_id': '0000-0001-cooker_hood_mode', @@ -75,6 +76,7 @@ 'original_name': 'Electric heater', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'electric_heater', 'unique_id': '0000-0001-electric_heater', @@ -123,6 +125,7 @@ 'original_name': 'Fireplace mode', 'platform': 'flexit_bacnet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fireplace_mode', 'unique_id': '0000-0001-fireplace_mode', diff --git a/tests/components/flexit_bacnet/test_number.py b/tests/components/flexit_bacnet/test_number.py index f566b623f12..1053521dc2d 100644 --- a/tests/components/flexit_bacnet/test_number.py +++ b/tests/components/flexit_bacnet/test_number.py @@ -60,7 +60,7 @@ async def test_numbers_implementation( blocking=True, ) - mocked_method = getattr(mock_flexit_bacnet, "set_fan_setpoint_supply_air_fire") + mocked_method = mock_flexit_bacnet.set_fan_setpoint_supply_air_fire assert len(mocked_method.mock_calls) == 1 assert hass.states.get(ENTITY_ID).state == "60" @@ -76,7 +76,7 @@ async def test_numbers_implementation( blocking=True, ) - mocked_method = getattr(mock_flexit_bacnet, "set_fan_setpoint_supply_air_fire") + mocked_method = mock_flexit_bacnet.set_fan_setpoint_supply_air_fire assert len(mocked_method.mock_calls) == 2 assert hass.states.get(ENTITY_ID).state == "40" @@ -94,7 +94,7 @@ async def test_numbers_implementation( blocking=True, ) - mocked_method = getattr(mock_flexit_bacnet, "set_fan_setpoint_supply_air_fire") + mocked_method = mock_flexit_bacnet.set_fan_setpoint_supply_air_fire assert len(mocked_method.mock_calls) == 3 mock_flexit_bacnet.set_fan_setpoint_supply_air_fire.side_effect = None diff --git a/tests/components/flexit_bacnet/test_switch.py b/tests/components/flexit_bacnet/test_switch.py index 8ce0bf11977..434e5fe1968 100644 --- a/tests/components/flexit_bacnet/test_switch.py +++ b/tests/components/flexit_bacnet/test_switch.py @@ -59,7 +59,7 @@ async def test_switches_implementation( blocking=True, ) - mocked_method = getattr(mock_flexit_bacnet, "disable_electric_heater") + mocked_method = mock_flexit_bacnet.disable_electric_heater assert len(mocked_method.mock_calls) == 1 assert hass.states.get(ENTITY_ID).state == STATE_OFF @@ -73,7 +73,7 @@ async def test_switches_implementation( blocking=True, ) - mocked_method = getattr(mock_flexit_bacnet, "enable_electric_heater") + mocked_method = mock_flexit_bacnet.enable_electric_heater assert len(mocked_method.mock_calls) == 1 assert hass.states.get(ENTITY_ID).state == STATE_ON @@ -88,7 +88,7 @@ async def test_switches_implementation( blocking=True, ) - mocked_method = getattr(mock_flexit_bacnet, "disable_electric_heater") + mocked_method = mock_flexit_bacnet.disable_electric_heater assert len(mocked_method.mock_calls) == 2 mock_flexit_bacnet.disable_electric_heater.side_effect = None @@ -114,7 +114,7 @@ async def test_switches_implementation( blocking=True, ) - mocked_method = getattr(mock_flexit_bacnet, "enable_electric_heater") + mocked_method = mock_flexit_bacnet.enable_electric_heater assert len(mocked_method.mock_calls) == 2 mock_flexit_bacnet.enable_electric_heater.side_effect = None diff --git a/tests/components/flo/test_init.py b/tests/components/flo/test_init.py index c1983b898da..8dfa712ecb1 100644 --- a/tests/components/flo/test_init.py +++ b/tests/components/flo/test_init.py @@ -1,7 +1,7 @@ """Test init.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/folder_watcher/conftest.py b/tests/components/folder_watcher/conftest.py index ed0adea7a7d..1c7744fa8f5 100644 --- a/tests/components/folder_watcher/conftest.py +++ b/tests/components/folder_watcher/conftest.py @@ -36,7 +36,7 @@ async def load_int( config_entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - title=f"Folder Watcher {path!s}", + title=f"Folder Watcher {tmp_path.parts[-1]!s}", data={}, options={"folder": str(path), "patterns": ["*"]}, entry_id="1", diff --git a/tests/components/folder_watcher/snapshots/test_event.ambr b/tests/components/folder_watcher/snapshots/test_event.ambr index 1101380703a..1514a9121c6 100644 --- a/tests/components/folder_watcher/snapshots/test_event.ambr +++ b/tests/components/folder_watcher/snapshots/test_event.ambr @@ -34,6 +34,7 @@ 'original_name': None, 'platform': 'folder_watcher', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'folder_watcher', 'unique_id': '1', diff --git a/tests/components/forecast_solar/test_diagnostics.py b/tests/components/forecast_solar/test_diagnostics.py index 0e80fba7647..e29b4a468ab 100644 --- a/tests/components/forecast_solar/test_diagnostics.py +++ b/tests/components/forecast_solar/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Forecast.Solar integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index 481ec3c0c9d..680a30580cb 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch from forecast_solar import ForecastSolarConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index f78ca894acb..86bf4c6b392 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -194,17 +194,17 @@ async def test_disabled_by_default( [ ( "power_production_next_12hours", - "Estimated power production - next 12 hours", + "Estimated power production - in 12 hours", "600000", ), ( "power_production_next_24hours", - "Estimated power production - next 24 hours", + "Estimated power production - in 24 hours", "700000", ), ( "power_production_next_hour", - "Estimated power production - next hour", + "Estimated power production - in 1 hour", "400000", ), ], diff --git a/tests/components/freebox/test_init.py b/tests/components/freebox/test_init.py index 4be58f247cd..c696ba838be 100644 --- a/tests/components/freebox/test_init.py +++ b/tests/components/freebox/test_init.py @@ -1,11 +1,11 @@ """Tests for the Freebox init.""" -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, Mock from pytest_unordered import unordered from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN -from homeassistant.components.freebox.const import DOMAIN, SERVICE_REBOOT +from homeassistant.components.freebox.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -33,19 +33,6 @@ async def test_setup(hass: HomeAssistant, router: Mock) -> None: assert router.call_count == 1 assert router().open.call_count == 1 - assert hass.services.has_service(DOMAIN, SERVICE_REBOOT) - - with patch( - "homeassistant.components.freebox.router.FreeboxRouter.reboot" - ) as mock_service: - await hass.services.async_call( - DOMAIN, - SERVICE_REBOOT, - blocking=True, - ) - await hass.async_block_till_done() - mock_service.assert_called_once() - async def test_setup_import(hass: HomeAssistant, router: Mock) -> None: """Test setup of integration from import.""" @@ -65,8 +52,6 @@ async def test_setup_import(hass: HomeAssistant, router: Mock) -> None: assert router.call_count == 1 assert router().open.call_count == 1 - assert hass.services.has_service(DOMAIN, SERVICE_REBOOT) - async def test_unload_remove(hass: HomeAssistant, router: Mock) -> None: """Test unload and remove of integration.""" @@ -106,7 +91,6 @@ async def test_unload_remove(hass: HomeAssistant, router: Mock) -> None: assert state_switch.state == STATE_UNAVAILABLE assert router().close.call_count == 1 - assert not hass.services.has_service(DOMAIN, SERVICE_REBOOT) await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/fritz/snapshots/test_button.ambr b/tests/components/fritz/snapshots/test_button.ambr index 748d8c1ba29..ac222fa72d3 100644 --- a/tests/components/fritz/snapshots/test_button.ambr +++ b/tests/components/fritz/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Cleanup', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cleanup', 'unique_id': '1C:ED:6F:12:34:11-cleanup', @@ -74,6 +75,7 @@ 'original_name': 'Firmware update', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'firmware_update', 'unique_id': '1C:ED:6F:12:34:11-firmware_update', @@ -122,6 +124,7 @@ 'original_name': 'Reconnect', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reconnect', 'unique_id': '1C:ED:6F:12:34:11-reconnect', @@ -170,6 +173,7 @@ 'original_name': 'Restart', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-reboot', @@ -218,6 +222,7 @@ 'original_name': 'printer Wake on LAN', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:BB:CC:00:11:22_wake_on_lan', diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index ffdd3d23f50..4efae5951e8 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Connection uptime', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connection_uptime', 'unique_id': '1C:ED:6F:12:34:11-connection_uptime', @@ -71,12 +72,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Download throughput', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'kb_s_received', 'unique_id': '1C:ED:6F:12:34:11-kb_s_received', @@ -127,6 +132,7 @@ 'original_name': 'External IP', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'external_ip', 'unique_id': '1C:ED:6F:12:34:11-external_ip', @@ -174,6 +180,7 @@ 'original_name': 'External IPv6', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'external_ipv6', 'unique_id': '1C:ED:6F:12:34:11-external_ipv6', @@ -217,12 +224,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'GB received', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gb_received', 'unique_id': '1C:ED:6F:12:34:11-gb_received', @@ -269,12 +280,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'GB sent', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gb_sent', 'unique_id': '1C:ED:6F:12:34:11-gb_sent', @@ -325,6 +340,7 @@ 'original_name': 'Last restart', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_uptime', 'unique_id': '1C:ED:6F:12:34:11-device_uptime', @@ -373,6 +389,7 @@ 'original_name': 'Link download noise margin', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_noise_margin_received', 'unique_id': '1C:ED:6F:12:34:11-link_noise_margin_received', @@ -421,6 +438,7 @@ 'original_name': 'Link download power attenuation', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_attenuation_received', 'unique_id': '1C:ED:6F:12:34:11-link_attenuation_received', @@ -463,12 +481,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Link download throughput', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_kb_s_received', 'unique_id': '1C:ED:6F:12:34:11-link_kb_s_received', @@ -518,6 +540,7 @@ 'original_name': 'Link upload noise margin', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_noise_margin_sent', 'unique_id': '1C:ED:6F:12:34:11-link_noise_margin_sent', @@ -566,6 +589,7 @@ 'original_name': 'Link upload power attenuation', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_attenuation_sent', 'unique_id': '1C:ED:6F:12:34:11-link_attenuation_sent', @@ -608,12 +632,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Link upload throughput', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_kb_s_sent', 'unique_id': '1C:ED:6F:12:34:11-link_kb_s_sent', @@ -657,12 +685,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Max connection download throughput', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_kb_s_received', 'unique_id': '1C:ED:6F:12:34:11-max_kb_s_received', @@ -706,12 +738,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Max connection upload throughput', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_kb_s_sent', 'unique_id': '1C:ED:6F:12:34:11-max_kb_s_sent', @@ -757,12 +793,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Upload throughput', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'kb_s_sent', 'unique_id': '1C:ED:6F:12:34:11-kb_s_sent', diff --git a/tests/components/fritz/snapshots/test_switch.ambr b/tests/components/fritz/snapshots/test_switch.ambr index a1097d3333b..08046c988d6 100644 --- a/tests/components/fritz/snapshots/test_switch.ambr +++ b/tests/components/fritz/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_2_4ghz', @@ -75,6 +76,7 @@ 'original_name': 'Mock Title Wi-Fi WiFi (5Ghz)', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_5ghz', @@ -123,6 +125,7 @@ 'original_name': 'printer Internet Access', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:BB:CC:00:11:22_internet_access', @@ -171,6 +174,7 @@ 'original_name': 'Mock Title Wi-Fi WiFi', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi', @@ -219,6 +223,7 @@ 'original_name': 'Mock Title Wi-Fi WiFi2', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi2', @@ -267,6 +272,7 @@ 'original_name': 'printer Internet Access', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:BB:CC:00:11:22_internet_access', @@ -315,6 +321,7 @@ 'original_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_2_4ghz', @@ -363,6 +370,7 @@ 'original_name': 'Mock Title Wi-Fi WiFi+ (5Ghz)', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_5ghz', @@ -411,6 +419,7 @@ 'original_name': 'printer Internet Access', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:BB:CC:00:11:22_internet_access', @@ -459,6 +468,7 @@ 'original_name': 'Call deflection 0', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-call_deflection_0', @@ -513,6 +523,7 @@ 'original_name': 'Mock Title Wi-Fi MyWifi', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-wi_fi_mywifi', @@ -561,6 +572,7 @@ 'original_name': 'printer Internet Access', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:BB:CC:00:11:22_internet_access', diff --git a/tests/components/fritz/snapshots/test_update.ambr b/tests/components/fritz/snapshots/test_update.ambr index 746823e9dc9..ee683cc492f 100644 --- a/tests/components/fritz/snapshots/test_update.ambr +++ b/tests/components/fritz/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': 'FRITZ!OS', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-update', @@ -86,6 +87,7 @@ 'original_name': 'FRITZ!OS', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-update', @@ -145,6 +147,7 @@ 'original_name': 'FRITZ!OS', 'platform': 'fritz', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1C:ED:6F:12:34:11-update', diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index ee3ae881b2c..f790489c341 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.components.device_tracker import ( DEFAULT_CONSIDER_HOME, ) from homeassistant.components.fritz.const import ( + CONF_FEATURE_DEVICE_TRACKING, CONF_OLD_DISCOVERY, DOMAIN, ERROR_AUTH_INVALID, @@ -744,6 +745,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["data"] == { CONF_OLD_DISCOVERY: False, CONF_CONSIDER_HOME: 37, + CONF_FEATURE_DEVICE_TRACKING: True, } diff --git a/tests/components/fritz/test_diagnostics.py b/tests/components/fritz/test_diagnostics.py index cbcaa57dab4..84b06a3dd4a 100644 --- a/tests/components/fritz/test_diagnostics.py +++ b/tests/components/fritz/test_diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.fritz.const import DOMAIN diff --git a/tests/components/fritzbox/snapshots/test_binary_sensor.ambr b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr index 1d645947ceb..01d483fca2d 100644 --- a/tests/components/fritzbox/snapshots/test_binary_sensor.ambr +++ b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Alarm', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm', 'unique_id': '12345 1234567_alarm', @@ -75,6 +76,7 @@ 'original_name': 'Battery', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_battery_low', @@ -123,6 +125,7 @@ 'original_name': 'Button lock on device', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock', 'unique_id': '12345 1234567_lock', @@ -171,6 +174,7 @@ 'original_name': 'Button lock via UI', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_lock', 'unique_id': '12345 1234567_device_lock', @@ -219,6 +223,7 @@ 'original_name': 'Holiday mode', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'holiday_active', 'unique_id': '12345 1234567_holiday_active', @@ -266,6 +271,7 @@ 'original_name': 'Open window detected', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'window_open', 'unique_id': '12345 1234567_window_open', @@ -313,6 +319,7 @@ 'original_name': 'Summer mode', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'summer_active', 'unique_id': '12345 1234567_summer_active', diff --git a/tests/components/fritzbox/snapshots/test_button.ambr b/tests/components/fritzbox/snapshots/test_button.ambr index 95e757da3cc..fc5285cddc6 100644 --- a/tests/components/fritzbox/snapshots/test_button.ambr +++ b/tests/components/fritzbox/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'fake_name', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567', diff --git a/tests/components/fritzbox/snapshots/test_climate.ambr b/tests/components/fritzbox/snapshots/test_climate.ambr index 26e06105152..423472c078e 100644 --- a/tests/components/fritzbox/snapshots/test_climate.ambr +++ b/tests/components/fritzbox/snapshots/test_climate.ambr @@ -39,6 +39,7 @@ 'original_name': 'fake_name', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '12345 1234567', diff --git a/tests/components/fritzbox/snapshots/test_cover.ambr b/tests/components/fritzbox/snapshots/test_cover.ambr index ce6b305e154..6138086e140 100644 --- a/tests/components/fritzbox/snapshots/test_cover.ambr +++ b/tests/components/fritzbox/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': 'fake_name', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '12345 1234567', diff --git a/tests/components/fritzbox/snapshots/test_light.ambr b/tests/components/fritzbox/snapshots/test_light.ambr index f6f4516bdec..bb92b3133c6 100644 --- a/tests/components/fritzbox/snapshots/test_light.ambr +++ b/tests/components/fritzbox/snapshots/test_light.ambr @@ -36,6 +36,7 @@ 'original_name': 'fake_name', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567', @@ -118,6 +119,7 @@ 'original_name': 'fake_name', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567', @@ -195,6 +197,7 @@ 'original_name': 'fake_name', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567', @@ -252,6 +255,7 @@ 'original_name': 'fake_name', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567', diff --git a/tests/components/fritzbox/snapshots/test_sensor.ambr b/tests/components/fritzbox/snapshots/test_sensor.ambr index 68f8e161d07..bcf27e25fee 100644 --- a/tests/components/fritzbox/snapshots/test_sensor.ambr +++ b/tests/components/fritzbox/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_battery', @@ -81,6 +82,7 @@ 'original_name': 'Battery', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_battery', @@ -125,12 +127,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Comfort temperature', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'comfort_temperature', 'unique_id': '12345 1234567_comfort_temperature', @@ -180,6 +186,7 @@ 'original_name': 'Current scheduled preset', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'scheduled_preset', 'unique_id': '12345 1234567_scheduled_preset', @@ -221,12 +228,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Eco temperature', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'eco_temperature', 'unique_id': '12345 1234567_eco_temperature', @@ -276,6 +287,7 @@ 'original_name': 'Next scheduled change time', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextchange_time', 'unique_id': '12345 1234567_nextchange_time', @@ -324,6 +336,7 @@ 'original_name': 'Next scheduled preset', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextchange_preset', 'unique_id': '12345 1234567_nextchange_preset', @@ -365,12 +378,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Next scheduled temperature', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextchange_temperature', 'unique_id': '12345 1234567_nextchange_temperature', @@ -422,6 +439,7 @@ 'original_name': 'Battery', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_battery', @@ -474,6 +492,7 @@ 'original_name': 'Humidity', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_humidity', @@ -520,12 +539,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_temperature', @@ -572,12 +595,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_electric_current', @@ -624,12 +651,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_total_energy', @@ -676,12 +707,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_power_consumption', @@ -728,12 +763,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_temperature', @@ -780,12 +819,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567_voltage', diff --git a/tests/components/fritzbox/snapshots/test_switch.ambr b/tests/components/fritzbox/snapshots/test_switch.ambr index 23deb8183fc..b58c37a7619 100644 --- a/tests/components/fritzbox/snapshots/test_switch.ambr +++ b/tests/components/fritzbox/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'fake_name', 'platform': 'fritzbox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345 1234567', diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index 3eac2c24953..ae691f6107e 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 5280cd7cc83..ada50d7f16c 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import Mock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index bdf9dba8b42..e216f7d4b30 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -6,7 +6,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -609,7 +609,7 @@ async def test_holidy_summer_mode( assert state assert state.attributes[ATTR_STATE_HOLIDAY_MODE] assert state.attributes[ATTR_STATE_SUMMER_MODE] is False - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOLIDAY assert state.attributes[ATTR_PRESET_MODES] == [PRESET_HOLIDAY] @@ -645,7 +645,7 @@ async def test_holidy_summer_mode( assert state assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False assert state.attributes[ATTR_STATE_SUMMER_MODE] - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF] + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] == PRESET_SUMMER assert state.attributes[ATTR_PRESET_MODES] == [PRESET_SUMMER] diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index a1332e9715b..75e11983f39 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import Mock, call, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ATTR_POSITION, DOMAIN as COVER_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index d9a81bf8f21..7e6fa05d8cd 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import Mock, call, patch from requests.exceptions import HTTPError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritzbox.const import ( COLOR_MODE, diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 7912aaf8d12..4d12e8750a3 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index cb6b563d344..d8894c0ae93 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN diff --git a/tests/components/fronius/snapshots/test_sensor.ambr b/tests/components/fronius/snapshots/test_sensor.ambr index 63d2c85986a..14ca17d81c1 100644 --- a/tests/components/fronius/snapshots/test_sensor.ambr +++ b/tests/components/fronius/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC current', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac', 'unique_id': '12345678-current_ac', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_ac', 'unique_id': '12345678-power_ac', @@ -127,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC voltage', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac', 'unique_id': '12345678-voltage_ac', @@ -179,12 +191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC current', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_dc', 'unique_id': '12345678-current_dc', @@ -231,14 +247,18 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC current 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'current_dc_2', + 'translation_key': 'current_dc_mppt_no', 'unique_id': '12345678-current_dc_2', 'unit_of_measurement': , }) @@ -283,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC voltage', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_dc', 'unique_id': '12345678-voltage_dc', @@ -335,14 +359,18 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC voltage 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'voltage_dc_2', + 'translation_key': 'voltage_dc_mppt_no', 'unique_id': '12345678-voltage_dc_2', 'unit_of_measurement': , }) @@ -391,6 +419,7 @@ 'original_name': 'Error code', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error_code', 'unique_id': '12345678-error_code', @@ -537,6 +566,7 @@ 'original_name': 'Error message', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error_message', 'unique_id': '12345678-error_message', @@ -679,12 +709,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'frequency_ac', 'unique_id': '12345678-frequency_ac', @@ -735,6 +769,7 @@ 'original_name': 'Inverter state', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'inverter_state', 'unique_id': '12345678-inverter_state', @@ -782,6 +817,7 @@ 'original_name': 'Status code', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_code', 'unique_id': '12345678-status_code', @@ -840,6 +876,7 @@ 'original_name': 'Status message', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_message', 'unique_id': '12345678-status_message', @@ -894,12 +931,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_total', 'unique_id': '12345678-energy_total', @@ -946,12 +987,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_apparent', 'unique_id': '1234567890-power_apparent', @@ -998,12 +1043,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_apparent_phase_1', 'unique_id': '1234567890-power_apparent_phase_1', @@ -1050,12 +1099,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_apparent_phase_2', 'unique_id': '1234567890-power_apparent_phase_2', @@ -1102,12 +1155,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_apparent_phase_3', 'unique_id': '1234567890-power_apparent_phase_3', @@ -1154,12 +1211,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac_phase_1', 'unique_id': '1234567890-current_ac_phase_1', @@ -1206,12 +1267,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac_phase_2', 'unique_id': '1234567890-current_ac_phase_2', @@ -1258,12 +1323,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac_phase_3', 'unique_id': '1234567890-current_ac_phase_3', @@ -1310,12 +1379,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency phase average', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'frequency_phase_average', 'unique_id': '1234567890-frequency_phase_average', @@ -1366,6 +1439,7 @@ 'original_name': 'Meter location', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_location', 'unique_id': '1234567890-meter_location', @@ -1421,6 +1495,7 @@ 'original_name': 'Meter location description', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_location_description', 'unique_id': '1234567890-meter_location_description', @@ -1478,6 +1553,7 @@ 'original_name': 'Power factor', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_factor', 'unique_id': '1234567890-power_factor', @@ -1529,6 +1605,7 @@ 'original_name': 'Power factor phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_factor_phase_1', 'unique_id': '1234567890-power_factor_phase_1', @@ -1580,6 +1657,7 @@ 'original_name': 'Power factor phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_factor_phase_2', 'unique_id': '1234567890-power_factor_phase_2', @@ -1631,6 +1709,7 @@ 'original_name': 'Power factor phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_factor_phase_3', 'unique_id': '1234567890-power_factor_phase_3', @@ -1682,6 +1761,7 @@ 'original_name': 'Reactive energy consumed', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_reactive_ac_consumed', 'unique_id': '1234567890-energy_reactive_ac_consumed', @@ -1733,6 +1813,7 @@ 'original_name': 'Reactive energy produced', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_reactive_ac_produced', 'unique_id': '1234567890-energy_reactive_ac_produced', @@ -1778,12 +1859,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_reactive', 'unique_id': '1234567890-power_reactive', @@ -1830,12 +1915,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_reactive_phase_1', 'unique_id': '1234567890-power_reactive_phase_1', @@ -1882,12 +1971,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_reactive_phase_2', 'unique_id': '1234567890-power_reactive_phase_2', @@ -1934,12 +2027,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_reactive_phase_3', 'unique_id': '1234567890-power_reactive_phase_3', @@ -1986,12 +2083,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real energy consumed', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_real_consumed', 'unique_id': '1234567890-energy_real_consumed', @@ -2038,12 +2139,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real energy minus', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_real_ac_minus', 'unique_id': '1234567890-energy_real_ac_minus', @@ -2090,12 +2195,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real energy plus', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_real_ac_plus', 'unique_id': '1234567890-energy_real_ac_plus', @@ -2142,12 +2251,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real energy produced', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_real_produced', 'unique_id': '1234567890-energy_real_produced', @@ -2194,12 +2307,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real', 'unique_id': '1234567890-power_real', @@ -2246,12 +2363,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real power phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real_phase_1', 'unique_id': '1234567890-power_real_phase_1', @@ -2298,12 +2419,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real power phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real_phase_2', 'unique_id': '1234567890-power_real_phase_2', @@ -2350,12 +2475,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real power phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real_phase_3', 'unique_id': '1234567890-power_real_phase_3', @@ -2402,12 +2531,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_1', 'unique_id': '1234567890-voltage_ac_phase_1', @@ -2454,12 +2587,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1-2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_to_phase_12', 'unique_id': '1234567890-voltage_ac_phase_to_phase_12', @@ -2506,12 +2643,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_2', 'unique_id': '1234567890-voltage_ac_phase_2', @@ -2558,12 +2699,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2-3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_to_phase_23', 'unique_id': '1234567890-voltage_ac_phase_to_phase_23', @@ -2610,12 +2755,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_3', 'unique_id': '1234567890-voltage_ac_phase_3', @@ -2662,12 +2811,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3-1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_to_phase_31', 'unique_id': '1234567890-voltage_ac_phase_to_phase_31', @@ -2718,6 +2871,7 @@ 'original_name': 'Meter mode', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_mode', 'unique_id': 'solar_net_123.4567890-power_flow-meter_mode', @@ -2761,12 +2915,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power grid', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_grid', 'unique_id': 'solar_net_123.4567890-power_flow-power_grid', @@ -2813,12 +2971,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power grid export', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_grid_export', 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_export', @@ -2865,12 +3027,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power grid import', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_grid_import', 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_import', @@ -2917,12 +3083,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power load', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_load', 'unique_id': 'solar_net_123.4567890-power_flow-power_load', @@ -2969,12 +3139,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power load consumed', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_load_consumed', 'unique_id': 'solar_net_123.4567890-power_flow-power_load_consumed', @@ -3021,12 +3195,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power load generated', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_load_generated', 'unique_id': 'solar_net_123.4567890-power_flow-power_load_generated', @@ -3073,12 +3251,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power photovoltaics', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_photovoltaics', 'unique_id': 'solar_net_123.4567890-power_flow-power_photovoltaics', @@ -3131,6 +3313,7 @@ 'original_name': 'Relative autonomy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relative_autonomy', 'unique_id': 'solar_net_123.4567890-power_flow-relative_autonomy', @@ -3182,6 +3365,7 @@ 'original_name': 'Relative self-consumption', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relative_self_consumption', 'unique_id': 'solar_net_123.4567890-power_flow-relative_self_consumption', @@ -3227,12 +3411,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_total', 'unique_id': 'solar_net_123.4567890-power_flow-energy_total', @@ -3279,12 +3467,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC current', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_dc', 'unique_id': 'P030T020Z2001234567 -current_dc', @@ -3331,12 +3523,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC voltage', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_dc', 'unique_id': 'P030T020Z2001234567 -voltage_dc', @@ -3387,6 +3583,7 @@ 'original_name': 'Designed capacity', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'capacity_designed', 'unique_id': 'P030T020Z2001234567 -capacity_designed', @@ -3435,6 +3632,7 @@ 'original_name': 'Maximum capacity', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'capacity_maximum', 'unique_id': 'P030T020Z2001234567 -capacity_maximum', @@ -3485,6 +3683,7 @@ 'original_name': 'State of charge', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state_of_charge', 'unique_id': 'P030T020Z2001234567 -state_of_charge', @@ -3531,12 +3730,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_cell', 'unique_id': 'P030T020Z2001234567 -temperature_cell', @@ -3583,12 +3786,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC current', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac', 'unique_id': '12345678-current_ac', @@ -3635,12 +3842,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_ac', 'unique_id': '12345678-power_ac', @@ -3687,12 +3898,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC voltage', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac', 'unique_id': '12345678-voltage_ac', @@ -3739,12 +3954,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC current', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_dc', 'unique_id': '12345678-current_dc', @@ -3791,14 +4010,18 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC current 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'current_dc_2', + 'translation_key': 'current_dc_mppt_no', 'unique_id': '12345678-current_dc_2', 'unit_of_measurement': , }) @@ -3843,12 +4066,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC voltage', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_dc', 'unique_id': '12345678-voltage_dc', @@ -3895,14 +4122,18 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC voltage 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'voltage_dc_2', + 'translation_key': 'voltage_dc_mppt_no', 'unique_id': '12345678-voltage_dc_2', 'unit_of_measurement': , }) @@ -3951,6 +4182,7 @@ 'original_name': 'Error code', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error_code', 'unique_id': '12345678-error_code', @@ -4097,6 +4329,7 @@ 'original_name': 'Error message', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error_message', 'unique_id': '12345678-error_message', @@ -4239,12 +4472,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'frequency_ac', 'unique_id': '12345678-frequency_ac', @@ -4295,6 +4532,7 @@ 'original_name': 'Inverter state', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'inverter_state', 'unique_id': '12345678-inverter_state', @@ -4342,6 +4580,7 @@ 'original_name': 'Status code', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_code', 'unique_id': '12345678-status_code', @@ -4400,6 +4639,7 @@ 'original_name': 'Status message', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_message', 'unique_id': '12345678-status_message', @@ -4454,12 +4694,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_total', 'unique_id': '12345678-energy_total', @@ -4506,12 +4750,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy consumed', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_real_ac_consumed', 'unique_id': '23456789-energy_real_ac_consumed', @@ -4558,12 +4806,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real_ac', 'unique_id': '23456789-power_real_ac', @@ -4614,6 +4866,7 @@ 'original_name': 'State code', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state_code', 'unique_id': '23456789-state_code', @@ -4670,6 +4923,7 @@ 'original_name': 'State message', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state_message', 'unique_id': '23456789-state_message', @@ -4722,12 +4976,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_channel_1', 'unique_id': '23456789-temperature_channel_1', @@ -4774,12 +5032,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_apparent', 'unique_id': '1234567890-power_apparent', @@ -4826,12 +5088,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_apparent_phase_1', 'unique_id': '1234567890-power_apparent_phase_1', @@ -4878,12 +5144,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_apparent_phase_2', 'unique_id': '1234567890-power_apparent_phase_2', @@ -4930,12 +5200,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_apparent_phase_3', 'unique_id': '1234567890-power_apparent_phase_3', @@ -4982,12 +5256,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac_phase_1', 'unique_id': '1234567890-current_ac_phase_1', @@ -5034,12 +5312,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac_phase_2', 'unique_id': '1234567890-current_ac_phase_2', @@ -5086,12 +5368,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac_phase_3', 'unique_id': '1234567890-current_ac_phase_3', @@ -5138,12 +5424,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency phase average', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'frequency_phase_average', 'unique_id': '1234567890-frequency_phase_average', @@ -5194,6 +5484,7 @@ 'original_name': 'Meter location', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_location', 'unique_id': '1234567890-meter_location', @@ -5249,6 +5540,7 @@ 'original_name': 'Meter location description', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_location_description', 'unique_id': '1234567890-meter_location_description', @@ -5306,6 +5598,7 @@ 'original_name': 'Power factor', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_factor', 'unique_id': '1234567890-power_factor', @@ -5357,6 +5650,7 @@ 'original_name': 'Power factor phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_factor_phase_1', 'unique_id': '1234567890-power_factor_phase_1', @@ -5408,6 +5702,7 @@ 'original_name': 'Power factor phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_factor_phase_2', 'unique_id': '1234567890-power_factor_phase_2', @@ -5459,6 +5754,7 @@ 'original_name': 'Power factor phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_factor_phase_3', 'unique_id': '1234567890-power_factor_phase_3', @@ -5510,6 +5806,7 @@ 'original_name': 'Reactive energy consumed', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_reactive_ac_consumed', 'unique_id': '1234567890-energy_reactive_ac_consumed', @@ -5561,6 +5858,7 @@ 'original_name': 'Reactive energy produced', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_reactive_ac_produced', 'unique_id': '1234567890-energy_reactive_ac_produced', @@ -5606,12 +5904,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_reactive', 'unique_id': '1234567890-power_reactive', @@ -5658,12 +5960,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_reactive_phase_1', 'unique_id': '1234567890-power_reactive_phase_1', @@ -5710,12 +6016,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_reactive_phase_2', 'unique_id': '1234567890-power_reactive_phase_2', @@ -5762,12 +6072,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_reactive_phase_3', 'unique_id': '1234567890-power_reactive_phase_3', @@ -5814,12 +6128,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real energy consumed', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_real_consumed', 'unique_id': '1234567890-energy_real_consumed', @@ -5866,12 +6184,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real energy minus', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_real_ac_minus', 'unique_id': '1234567890-energy_real_ac_minus', @@ -5918,12 +6240,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real energy plus', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_real_ac_plus', 'unique_id': '1234567890-energy_real_ac_plus', @@ -5970,12 +6296,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real energy produced', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_real_produced', 'unique_id': '1234567890-energy_real_produced', @@ -6022,12 +6352,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real', 'unique_id': '1234567890-power_real', @@ -6074,12 +6408,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real power phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real_phase_1', 'unique_id': '1234567890-power_real_phase_1', @@ -6126,12 +6464,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real power phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real_phase_2', 'unique_id': '1234567890-power_real_phase_2', @@ -6178,12 +6520,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real power phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real_phase_3', 'unique_id': '1234567890-power_real_phase_3', @@ -6230,12 +6576,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_1', 'unique_id': '1234567890-voltage_ac_phase_1', @@ -6282,12 +6632,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1-2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_to_phase_12', 'unique_id': '1234567890-voltage_ac_phase_to_phase_12', @@ -6334,12 +6688,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_2', 'unique_id': '1234567890-voltage_ac_phase_2', @@ -6386,12 +6744,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2-3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_to_phase_23', 'unique_id': '1234567890-voltage_ac_phase_to_phase_23', @@ -6438,12 +6800,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_3', 'unique_id': '1234567890-voltage_ac_phase_3', @@ -6490,12 +6856,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3-1', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac_phase_to_phase_31', 'unique_id': '1234567890-voltage_ac_phase_to_phase_31', @@ -6546,6 +6916,7 @@ 'original_name': 'Meter mode', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_mode', 'unique_id': 'solar_net_12345678-power_flow-meter_mode', @@ -6589,12 +6960,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power battery', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_battery', 'unique_id': 'solar_net_12345678-power_flow-power_battery', @@ -6641,12 +7016,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power battery charge', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_battery_charge', 'unique_id': 'solar_net_12345678-power_flow-power_battery_charge', @@ -6693,12 +7072,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power battery discharge', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_battery_discharge', 'unique_id': 'solar_net_12345678-power_flow-power_battery_discharge', @@ -6745,12 +7128,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power grid', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_grid', 'unique_id': 'solar_net_12345678-power_flow-power_grid', @@ -6797,12 +7184,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power grid export', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_grid_export', 'unique_id': 'solar_net_12345678-power_flow-power_grid_export', @@ -6849,12 +7240,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power grid import', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_grid_import', 'unique_id': 'solar_net_12345678-power_flow-power_grid_import', @@ -6901,12 +7296,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power load', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_load', 'unique_id': 'solar_net_12345678-power_flow-power_load', @@ -6953,12 +7352,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power load consumed', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_load_consumed', 'unique_id': 'solar_net_12345678-power_flow-power_load_consumed', @@ -7005,12 +7408,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power load generated', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_load_generated', 'unique_id': 'solar_net_12345678-power_flow-power_load_generated', @@ -7057,12 +7464,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power photovoltaics', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_photovoltaics', 'unique_id': 'solar_net_12345678-power_flow-power_photovoltaics', @@ -7115,6 +7526,7 @@ 'original_name': 'Relative autonomy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relative_autonomy', 'unique_id': 'solar_net_12345678-power_flow-relative_autonomy', @@ -7166,6 +7578,7 @@ 'original_name': 'Relative self-consumption', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relative_self_consumption', 'unique_id': 'solar_net_12345678-power_flow-relative_self_consumption', @@ -7211,12 +7624,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_total', 'unique_id': 'solar_net_12345678-power_flow-energy_total', @@ -7263,12 +7680,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC current', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac', 'unique_id': '234567-current_ac', @@ -7315,12 +7736,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_ac', 'unique_id': '234567-power_ac', @@ -7367,12 +7792,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC voltage', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac', 'unique_id': '234567-voltage_ac', @@ -7419,12 +7848,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC current', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_dc', 'unique_id': '234567-current_dc', @@ -7471,12 +7904,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC voltage', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_dc', 'unique_id': '234567-voltage_dc', @@ -7523,12 +7960,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy day', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_day', 'unique_id': '234567-energy_day', @@ -7575,12 +8016,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy year', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_year', 'unique_id': '234567-energy_year', @@ -7631,6 +8076,7 @@ 'original_name': 'Error code', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error_code', 'unique_id': '234567-error_code', @@ -7777,6 +8223,7 @@ 'original_name': 'Error message', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error_message', 'unique_id': '234567-error_message', @@ -7919,12 +8366,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'frequency_ac', 'unique_id': '234567-frequency_ac', @@ -7975,6 +8426,7 @@ 'original_name': 'LED color', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_color', 'unique_id': '234567-led_color', @@ -8022,6 +8474,7 @@ 'original_name': 'LED state', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_state', 'unique_id': '234567-led_state', @@ -8069,6 +8522,7 @@ 'original_name': 'Status code', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_code', 'unique_id': '234567-status_code', @@ -8127,6 +8581,7 @@ 'original_name': 'Status message', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_message', 'unique_id': '234567-status_message', @@ -8181,12 +8636,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_total', 'unique_id': '234567-energy_total', @@ -8233,12 +8692,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC current', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_ac', 'unique_id': '123456-current_ac', @@ -8285,12 +8748,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_ac', 'unique_id': '123456-power_ac', @@ -8337,12 +8804,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'AC voltage', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac', 'unique_id': '123456-voltage_ac', @@ -8389,12 +8860,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC current', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_dc', 'unique_id': '123456-current_dc', @@ -8441,12 +8916,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC voltage', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_dc', 'unique_id': '123456-voltage_dc', @@ -8493,12 +8972,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy day', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_day', 'unique_id': '123456-energy_day', @@ -8545,12 +9028,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy year', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_year', 'unique_id': '123456-energy_year', @@ -8601,6 +9088,7 @@ 'original_name': 'Error code', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error_code', 'unique_id': '123456-error_code', @@ -8747,6 +9235,7 @@ 'original_name': 'Error message', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error_message', 'unique_id': '123456-error_message', @@ -8889,12 +9378,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'frequency_ac', 'unique_id': '123456-frequency_ac', @@ -8945,6 +9438,7 @@ 'original_name': 'LED color', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_color', 'unique_id': '123456-led_color', @@ -8992,6 +9486,7 @@ 'original_name': 'LED state', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_state', 'unique_id': '123456-led_state', @@ -9039,6 +9534,7 @@ 'original_name': 'Status code', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_code', 'unique_id': '123456-status_code', @@ -9097,6 +9593,7 @@ 'original_name': 'Status message', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_message', 'unique_id': '123456-status_message', @@ -9151,12 +9648,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_total', 'unique_id': '123456-energy_total', @@ -9207,6 +9708,7 @@ 'original_name': 'Meter location', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_location', 'unique_id': 'solar_net_123.4567890:S0 Meter at inverter 1-meter_location', @@ -9262,6 +9764,7 @@ 'original_name': 'Meter location description', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_location_description', 'unique_id': 'solar_net_123.4567890:S0 Meter at inverter 1-meter_location_description', @@ -9313,12 +9816,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Real power', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_real', 'unique_id': 'solar_net_123.4567890:S0 Meter at inverter 1-power_real', @@ -9371,6 +9878,7 @@ 'original_name': 'CO₂ factor', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2_factor', 'unique_id': '123.4567890-co2_factor', @@ -9416,12 +9924,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy day', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_day', 'unique_id': 'solar_net_123.4567890-power_flow-energy_day', @@ -9468,12 +9980,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy year', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_year', 'unique_id': 'solar_net_123.4567890-power_flow-energy_year', @@ -9526,6 +10042,7 @@ 'original_name': 'Grid export tariff', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cash_factor', 'unique_id': '123.4567890-cash_factor', @@ -9577,6 +10094,7 @@ 'original_name': 'Grid import tariff', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'delivery_factor', 'unique_id': '123.4567890-delivery_factor', @@ -9626,6 +10144,7 @@ 'original_name': 'Meter mode', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_mode', 'unique_id': 'solar_net_123.4567890-power_flow-meter_mode', @@ -9669,12 +10188,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power grid', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_grid', 'unique_id': 'solar_net_123.4567890-power_flow-power_grid', @@ -9721,12 +10244,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power grid export', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_grid_export', 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_export', @@ -9773,12 +10300,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power grid import', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_grid_import', 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_import', @@ -9825,12 +10356,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power load', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_load', 'unique_id': 'solar_net_123.4567890-power_flow-power_load', @@ -9877,12 +10412,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power load consumed', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_load_consumed', 'unique_id': 'solar_net_123.4567890-power_flow-power_load_consumed', @@ -9929,12 +10468,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power load generated', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_load_generated', 'unique_id': 'solar_net_123.4567890-power_flow-power_load_generated', @@ -9981,12 +10524,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power photovoltaics', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_photovoltaics', 'unique_id': 'solar_net_123.4567890-power_flow-power_photovoltaics', @@ -10039,6 +10586,7 @@ 'original_name': 'Relative autonomy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relative_autonomy', 'unique_id': 'solar_net_123.4567890-power_flow-relative_autonomy', @@ -10090,6 +10638,7 @@ 'original_name': 'Relative self-consumption', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relative_self_consumption', 'unique_id': 'solar_net_123.4567890-power_flow-relative_self_consumption', @@ -10135,12 +10684,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'fronius', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_total', 'unique_id': 'solar_net_123.4567890-power_flow-energy_total', diff --git a/tests/components/fronius/test_diagnostics.py b/tests/components/fronius/test_diagnostics.py index ddef5b4a18c..cb6faf547e2 100644 --- a/tests/components/fronius/test_diagnostics.py +++ b/tests/components/fronius/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Fronius integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 63f36705c8f..be8cd43cf2b 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -2,7 +2,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.coordinator import ( diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py index 360ca151551..f4a61b743c5 100644 --- a/tests/components/frontend/test_storage.py +++ b/tests/components/frontend/test_storage.py @@ -79,12 +79,46 @@ async def test_get_user_data( assert res["result"]["value"]["test-complex"][0]["foo"] == "bar" +@pytest.mark.parametrize( + ("subscriptions", "events"), + [ + ([], []), + ([(1, {}, {})], [(1, {"test-key": "test-value"})]), + ([(1, {"key": "test-key"}, None)], [(1, "test-value")]), + ([(1, {"key": "other-key"}, None)], []), + ], +) async def test_set_user_data_empty( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + subscriptions: list[tuple[int, dict[str, str], Any]], + events: list[tuple[int, Any]], ) -> None: - """Test set_user_data command.""" + """Test set_user_data command. + + Also test subscribing. + """ client = await hass_ws_client(hass) + for msg_id, key, event_data in subscriptions: + await client.send_json( + { + "id": msg_id, + "type": "frontend/subscribe_user_data", + } + | key + ) + + event = await client.receive_json() + assert event == { + "id": msg_id, + "type": "event", + "event": {"value": event_data}, + } + + res = await client.receive_json() + assert res["success"], res + # test creating await client.send_json( @@ -104,6 +138,10 @@ async def test_set_user_data_empty( } ) + for msg_id, event_data in events: + event = await client.receive_json() + assert event == {"id": msg_id, "type": "event", "event": {"value": event_data}} + res = await client.receive_json() assert res["success"], res @@ -116,11 +154,63 @@ async def test_set_user_data_empty( assert res["result"]["value"] == "test-value" +@pytest.mark.parametrize( + ("subscriptions", "events"), + [ + ( + [], + [[], []], + ), + ( + [(1, {}, {"test-key": "test-value", "test-complex": "string"})], + [ + [ + ( + 1, + { + "test-complex": "string", + "test-key": "test-value", + "test-non-existent-key": "test-value-new", + }, + ) + ], + [ + ( + 1, + { + "test-complex": [{"foo": "bar"}], + "test-key": "test-value", + "test-non-existent-key": "test-value-new", + }, + ) + ], + ], + ), + ( + [(1, {"key": "test-key"}, "test-value")], + [[], []], + ), + ( + [(1, {"key": "test-non-existent-key"}, None)], + [[(1, "test-value-new")], []], + ), + ( + [(1, {"key": "test-complex"}, "string")], + [[], [(1, [{"foo": "bar"}])]], + ), + ( + [(1, {"key": "other-key"}, None)], + [[], []], + ), + ], +) async def test_set_user_data( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], hass_admin_user: MockUser, + subscriptions: list[tuple[int, dict[str, str], Any]], + events: list[list[tuple[int, Any]]], ) -> None: """Test set_user_data command with initial data.""" storage_key = f"{DOMAIN}.user_data_{hass_admin_user.id}" @@ -131,6 +221,25 @@ async def test_set_user_data( client = await hass_ws_client(hass) + for msg_id, key, event_data in subscriptions: + await client.send_json( + { + "id": msg_id, + "type": "frontend/subscribe_user_data", + } + | key + ) + + event = await client.receive_json() + assert event == { + "id": msg_id, + "type": "event", + "event": {"value": event_data}, + } + + res = await client.receive_json() + assert res["success"], res + # test creating await client.send_json( @@ -142,6 +251,10 @@ async def test_set_user_data( } ) + for msg_id, event_data in events[0]: + event = await client.receive_json() + assert event == {"id": msg_id, "type": "event", "event": {"value": event_data}} + res = await client.receive_json() assert res["success"], res @@ -164,6 +277,10 @@ async def test_set_user_data( } ) + for msg_id, event_data in events[1]: + event = await client.receive_json() + assert event == {"id": msg_id, "type": "event", "event": {"value": event_data}} + res = await client.receive_json() assert res["success"], res diff --git a/tests/components/fujitsu_fglair/snapshots/test_climate.ambr b/tests/components/fujitsu_fglair/snapshots/test_climate.ambr index 21c5b3429f4..e432d6a258a 100644 --- a/tests/components/fujitsu_fglair/snapshots/test_climate.ambr +++ b/tests/components/fujitsu_fglair/snapshots/test_climate.ambr @@ -49,6 +49,7 @@ 'original_name': None, 'platform': 'fujitsu_fglair', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'testserial123', @@ -144,6 +145,7 @@ 'original_name': None, 'platform': 'fujitsu_fglair', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'testserial345', diff --git a/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr index 751ad3cd2d9..e5dcda8d1a5 100644 --- a/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr +++ b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'fujitsu_fglair', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fglair_outside_temp', 'unique_id': 'testserial123_outside_temperature', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'fujitsu_fglair', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fglair_outside_temp', 'unique_id': 'testserial345_outside_temperature', diff --git a/tests/components/fujitsu_fglair/test_climate.py b/tests/components/fujitsu_fglair/test_climate.py index 676ff97f26a..4e9dc750af9 100644 --- a/tests/components/fujitsu_fglair/test_climate.py +++ b/tests/components/fujitsu_fglair/test_climate.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, diff --git a/tests/components/fujitsu_fglair/test_sensor.py b/tests/components/fujitsu_fglair/test_sensor.py index b8200f114ad..45d455200fb 100644 --- a/tests/components/fujitsu_fglair/test_sensor.py +++ b/tests/components/fujitsu_fglair/test_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/fyta/fixtures/plant_status1_update.json b/tests/components/fyta/fixtures/plant_status1_update.json index 5363c5bd290..85f77a014a7 100644 --- a/tests/components/fyta/fixtures/plant_status1_update.json +++ b/tests/components/fyta/fixtures/plant_status1_update.json @@ -25,7 +25,7 @@ "sw_version": "1.0", "status": 1, "online": true, - "origin_path": "http://www.plant_picture.com/user_picture", + "origin_path": "http://www.plant_picture.com/user_picture1", "ph": null, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture1", diff --git a/tests/components/fyta/snapshots/test_binary_sensor.ambr b/tests/components/fyta/snapshots/test_binary_sensor.ambr index 1218a3da71c..4483c9cdb86 100644 --- a/tests/components/fyta/snapshots/test_binary_sensor.ambr +++ b/tests/components/fyta/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-low_battery', @@ -75,6 +76,7 @@ 'original_name': 'Light notification', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_light', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_light', @@ -122,6 +124,7 @@ 'original_name': 'Nutrition notification', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_nutrition', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_nutrition', @@ -169,6 +172,7 @@ 'original_name': 'Productive plant', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'productive_plant', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-productive_plant', @@ -216,6 +220,7 @@ 'original_name': 'Repotted', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'repotted', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-repotted', @@ -263,6 +268,7 @@ 'original_name': 'Temperature notification', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_temperature', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_temperature', @@ -310,6 +316,7 @@ 'original_name': 'Update', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-sensor_update_available', @@ -358,6 +365,7 @@ 'original_name': 'Water notification', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_water', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_water', @@ -405,6 +413,7 @@ 'original_name': 'Battery', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-low_battery', @@ -453,6 +462,7 @@ 'original_name': 'Light notification', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_light', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_light', @@ -500,6 +510,7 @@ 'original_name': 'Nutrition notification', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_nutrition', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_nutrition', @@ -547,6 +558,7 @@ 'original_name': 'Productive plant', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'productive_plant', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-productive_plant', @@ -594,6 +606,7 @@ 'original_name': 'Repotted', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'repotted', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-repotted', @@ -641,6 +654,7 @@ 'original_name': 'Temperature notification', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_temperature', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_temperature', @@ -688,6 +702,7 @@ 'original_name': 'Update', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-sensor_update_available', @@ -736,6 +751,7 @@ 'original_name': 'Water notification', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_water', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_water', diff --git a/tests/components/fyta/snapshots/test_image.ambr b/tests/components/fyta/snapshots/test_image.ambr index cb39efb4500..fd39c372b28 100644 --- a/tests/components/fyta/snapshots/test_image.ambr +++ b/tests/components/fyta/snapshots/test_image.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[image.gummibaum-entry] +# name: test_all_entities[image.gummibaum_plant_image-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,7 +12,7 @@ 'disabled_by': None, 'domain': 'image', 'entity_category': None, - 'entity_id': 'image.gummibaum', + 'entity_id': 'image.gummibaum_plant_image', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24,31 +24,32 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Plant image', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'plant_image', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[image.gummibaum-state] +# name: test_all_entities[image.gummibaum_plant_image-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'access_token': '1', - 'entity_picture': '/api/image_proxy/image.gummibaum?token=1', - 'friendly_name': 'Gummibaum', + 'entity_picture': '/api/image_proxy/image.gummibaum_plant_image?token=1', + 'friendly_name': 'Gummibaum Plant image', }), 'context': , - 'entity_id': 'image.gummibaum', + 'entity_id': 'image.gummibaum_plant_image', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_all_entities[image.kakaobaum-entry] +# name: test_all_entities[image.gummibaum_user_image-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -61,7 +62,7 @@ 'disabled_by': None, 'domain': 'image', 'entity_category': None, - 'entity_id': 'image.kakaobaum', + 'entity_id': 'image.gummibaum_user_image', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -73,27 +74,134 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'User image', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image', + 'translation_key': 'plant_image_user', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image_user', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[image.kakaobaum-state] +# name: test_all_entities[image.gummibaum_user_image-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'access_token': '1', - 'entity_picture': '/api/image_proxy/image.kakaobaum?token=1', - 'friendly_name': 'Kakaobaum', + 'entity_picture': '/api/image_proxy/image.gummibaum_user_image?token=1', + 'friendly_name': 'Gummibaum User image', }), 'context': , - 'entity_id': 'image.kakaobaum', + 'entity_id': 'image.gummibaum_user_image', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- +# name: test_all_entities[image.kakaobaum_plant_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.kakaobaum_plant_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plant image', + 'platform': 'fyta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plant_image', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.kakaobaum_plant_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.kakaobaum_plant_image?token=1', + 'friendly_name': 'Kakaobaum Plant image', + }), + 'context': , + 'entity_id': 'image.kakaobaum_plant_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[image.kakaobaum_user_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.kakaobaum_user_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'User image', + 'platform': 'fyta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plant_image_user', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image_user', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.kakaobaum_user_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.kakaobaum_user_image?token=1', + 'friendly_name': 'Kakaobaum User image', + }), + 'context': , + 'entity_id': 'image.kakaobaum_user_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_user_image + None +# --- +# name: test_update_user_image.1 + b'd' +# --- diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr index c43a7446f11..5227755d852 100644 --- a/tests/components/fyta/snapshots/test_sensor.ambr +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-battery_level', @@ -79,6 +80,7 @@ 'original_name': 'Last fertilized', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_fertilised', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-fertilise_last', @@ -129,6 +131,7 @@ 'original_name': 'Light', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-light', @@ -187,6 +190,7 @@ 'original_name': 'Light state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-light_status', @@ -245,6 +249,7 @@ 'original_name': 'Moisture', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-moisture', @@ -304,6 +309,7 @@ 'original_name': 'Moisture state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'moisture_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-moisture_status', @@ -360,6 +366,7 @@ 'original_name': 'Next fertilization', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'next_fertilisation', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-fertilise_next', @@ -417,6 +424,7 @@ 'original_name': 'Nutrients state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nutrients_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-nutrients_status', @@ -475,6 +483,7 @@ 'original_name': 'pH', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-ph', @@ -531,6 +540,7 @@ 'original_name': 'Plant state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plant_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-status', @@ -581,12 +591,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Salinity', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salinity', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-salinity', @@ -646,6 +660,7 @@ 'original_name': 'Salinity state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salinity_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-salinity_status', @@ -702,6 +717,7 @@ 'original_name': 'Scientific name', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'scientific_name', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-scientific_name', @@ -745,12 +761,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-temperature', @@ -810,6 +830,7 @@ 'original_name': 'Temperature state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-temperature_status', @@ -868,6 +889,7 @@ 'original_name': 'Battery', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-battery_level', @@ -918,6 +940,7 @@ 'original_name': 'Last fertilized', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_fertilised', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-fertilise_last', @@ -968,6 +991,7 @@ 'original_name': 'Light', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-light', @@ -1026,6 +1050,7 @@ 'original_name': 'Light state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-light_status', @@ -1084,6 +1109,7 @@ 'original_name': 'Moisture', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-moisture', @@ -1143,6 +1169,7 @@ 'original_name': 'Moisture state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'moisture_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-moisture_status', @@ -1199,6 +1226,7 @@ 'original_name': 'Next fertilization', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'next_fertilisation', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-fertilise_next', @@ -1256,6 +1284,7 @@ 'original_name': 'Nutrients state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nutrients_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-nutrients_status', @@ -1314,6 +1343,7 @@ 'original_name': 'pH', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-ph', @@ -1370,6 +1400,7 @@ 'original_name': 'Plant state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plant_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-status', @@ -1420,12 +1451,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Salinity', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salinity', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-salinity', @@ -1485,6 +1520,7 @@ 'original_name': 'Salinity state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salinity_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-salinity_status', @@ -1541,6 +1577,7 @@ 'original_name': 'Scientific name', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'scientific_name', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-scientific_name', @@ -1584,12 +1621,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-temperature', @@ -1649,6 +1690,7 @@ 'original_name': 'Temperature state', 'platform': 'fyta', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_status', 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-temperature_status', diff --git a/tests/components/fyta/test_binary_sensor.py b/tests/components/fyta/test_binary_sensor.py index 9d6a4ae3b0e..aa5c45b6ebc 100644 --- a/tests/components/fyta/test_binary_sensor.py +++ b/tests/components/fyta/test_binary_sensor.py @@ -7,7 +7,7 @@ from freezegun.api import FrozenDateTimeFactory from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError from fyta_cli.fyta_models import Plant import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform diff --git a/tests/components/fyta/test_diagnostics.py b/tests/components/fyta/test_diagnostics.py index cfaa5484b82..1fb626756e5 100644 --- a/tests/components/fyta/test_diagnostics.py +++ b/tests/components/fyta/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.const import Platform diff --git a/tests/components/fyta/test_image.py b/tests/components/fyta/test_image.py index 4feb125bd15..2a0c71d68cc 100644 --- a/tests/components/fyta/test_image.py +++ b/tests/components/fyta/test_image.py @@ -1,13 +1,14 @@ """Test the Home Assistant fyta sensor module.""" from datetime import timedelta +from http import HTTPStatus from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError from fyta_cli.fyta_models import Plant import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN from homeassistant.components.image import ImageEntity @@ -23,6 +24,7 @@ from tests.common import ( load_json_object_fixture, snapshot_platform, ) +from tests.typing import ClientSessionGenerator async def test_all_entities( @@ -37,7 +39,7 @@ async def test_all_entities( await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - assert len(hass.states.async_all("image")) == 2 + assert len(hass.states.async_all("image")) == 4 @pytest.mark.parametrize( @@ -63,7 +65,8 @@ async def test_connection_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("image.gummibaum").state == STATE_UNAVAILABLE + assert hass.states.get("image.gummibaum_plant_image").state == STATE_UNAVAILABLE + assert hass.states.get("image.gummibaum_user_image").state == STATE_UNAVAILABLE async def test_add_remove_entities( @@ -76,7 +79,8 @@ async def test_add_remove_entities( await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) - assert hass.states.get("image.gummibaum") is not None + assert hass.states.get("image.gummibaum_plant_image") is not None + assert hass.states.get("image.gummibaum_user_image") is not None plants: dict[int, Plant] = { 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), @@ -92,8 +96,10 @@ async def test_add_remove_entities( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("image.kakaobaum") is None - assert hass.states.get("image.tomatenpflanze") is not None + assert hass.states.get("image.kakaobaum_plant_image") is None + assert hass.states.get("image.kakaobaum_user_image") is None + assert hass.states.get("image.tomatenpflanze_plant_image") is not None + assert hass.states.get("image.tomatenpflanze_user_image") is not None async def test_update_image( @@ -106,7 +112,10 @@ async def test_update_image( await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) - image_entity: ImageEntity = hass.data["domain_entities"]["image"]["image.gummibaum"] + image_entity: ImageEntity = hass.data["domain_entities"]["image"][ + "image.gummibaum_plant_image" + ] + image_state_1 = hass.states.get("image.gummibaum_plant_image") assert image_entity.image_url == "http://www.plant_picture.com/picture" @@ -126,4 +135,77 @@ async def test_update_image( async_fire_time_changed(hass) await hass.async_block_till_done() + image_state_2 = hass.states.get("image.gummibaum_plant_image") + assert image_entity.image_url == "http://www.plant_picture.com/picture1" + assert image_state_1 != image_state_2 + + +async def test_update_user_image_error( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test error during user picture update.""" + + mock_fyta_connector.get_plant_image.return_value = AsyncMock(return_value=None) + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + mock_fyta_connector.get_plant_image.return_value = None + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + image_entity: ImageEntity = hass.data["domain_entities"]["image"][ + "image.gummibaum_user_image" + ] + + assert image_entity.image_url == "http://www.plant_picture.com/user_picture" + assert image_entity._cached_image is None + + # Validate no image is available + client = await hass_client() + resp = await client.get("/api/image_proxy/image.gummibaum_user_image?token=1") + assert resp.status == 500 + + +async def test_update_user_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test if entity user picture is updated.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + mock_fyta_connector.get_plant_image.return_value = ( + "image/png", + bytes([100]), + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + image_entity: ImageEntity = hass.data["domain_entities"]["image"][ + "image.gummibaum_user_image" + ] + + assert image_entity.image_url == "http://www.plant_picture.com/user_picture" + image = image_entity._cached_image + assert image == snapshot + + # Validate image + client = await hass_client() + resp = await client.get("/api/image_proxy/image.gummibaum_user_image?token=1") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == snapshot diff --git a/tests/components/fyta/test_sensor.py b/tests/components/fyta/test_sensor.py index 07e3965e66f..e9835ff5dfc 100644 --- a/tests/components/fyta/test_sensor.py +++ b/tests/components/fyta/test_sensor.py @@ -7,7 +7,7 @@ from freezegun.api import FrozenDateTimeFactory from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError from fyta_cli.fyta_models import Plant import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr index b93a8656ecc..d70ebc38b2c 100644 --- a/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr +++ b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'State', 'platform': 'garages_amsterdam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state', 'unique_id': 'IJDok-state', diff --git a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr index 3453817da10..f47d8b9788a 100644 --- a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr +++ b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Long parking capacity', 'platform': 'garages_amsterdam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'long_capacity', 'unique_id': 'IJDok-long_capacity', @@ -78,6 +79,7 @@ 'original_name': 'Long parking free space', 'platform': 'garages_amsterdam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'free_space_long', 'unique_id': 'IJDok-free_space_long', @@ -128,6 +130,7 @@ 'original_name': 'Short parking capacity', 'platform': 'garages_amsterdam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'short_capacity', 'unique_id': 'IJDok-short_capacity', @@ -179,6 +182,7 @@ 'original_name': 'Short parking free space', 'platform': 'garages_amsterdam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'free_space_short', 'unique_id': 'IJDok-free_space_short', diff --git a/tests/components/garages_amsterdam/test_binary_sensor.py b/tests/components/garages_amsterdam/test_binary_sensor.py index b7d0333f7e3..b610ad484e8 100644 --- a/tests/components/garages_amsterdam/test_binary_sensor.py +++ b/tests/components/garages_amsterdam/test_binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/garages_amsterdam/test_sensor.py b/tests/components/garages_amsterdam/test_sensor.py index bc36401ea47..5e573cf3100 100644 --- a/tests/components/garages_amsterdam/test_sensor.py +++ b/tests/components/garages_amsterdam/test_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/gdacs/conftest.py b/tests/components/gdacs/conftest.py index 9d9a91aa407..46ac1d0aab2 100644 --- a/tests/components/gdacs/conftest.py +++ b/tests/components/gdacs/conftest.py @@ -2,7 +2,7 @@ import pytest -from homeassistant.components.gdacs import CONF_CATEGORIES, DOMAIN +from homeassistant.components.gdacs.const import CONF_CATEGORIES, DOMAIN from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, diff --git a/tests/components/gdacs/test_config_flow.py b/tests/components/gdacs/test_config_flow.py index f11848162cd..da9b2f7c9bf 100644 --- a/tests/components/gdacs/test_config_flow.py +++ b/tests/components/gdacs/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.gdacs import CONF_CATEGORIES, DOMAIN +from homeassistant.components.gdacs.const import CONF_CATEGORIES, DOMAIN from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, diff --git a/tests/components/gdacs/test_diagnostics.py b/tests/components/gdacs/test_diagnostics.py index 3c6cf4080a6..8e8882ff6e7 100644 --- a/tests/components/gdacs/test_diagnostics.py +++ b/tests/components/gdacs/test_diagnostics.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index 68e2d061259..a6937f80d59 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -5,7 +5,7 @@ from unittest.mock import patch from freezegun import freeze_time -from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED +from homeassistant.components.gdacs.const import DEFAULT_SCAN_INTERVAL from homeassistant.components.gdacs.geo_location import ( ATTR_ALERT_LEVEL, ATTR_COUNTRY, @@ -251,10 +251,7 @@ async def test_setup_imperial( ) # Test conversion of 200 miles to kilometers. - feeds = hass.data[DOMAIN][FEED] - assert feeds is not None - assert len(feeds) == 1 - manager = list(feeds.values())[0] + manager = config_entry.runtime_data # Ensure that the filter value in km is correctly set. assert manager._feed_manager._feed._filter_radius == 321.8688 diff --git a/tests/components/gdacs/test_init.py b/tests/components/gdacs/test_init.py index 1da4b0d9b9f..bdd11242b25 100644 --- a/tests/components/gdacs/test_init.py +++ b/tests/components/gdacs/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import patch -from homeassistant.components.gdacs import DOMAIN, FEED from homeassistant.core import HomeAssistant @@ -14,8 +13,7 @@ async def test_component_unload_config_entry(hass: HomeAssistant, config_entry) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None + # Unload config entry. assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index 01609cf485e..abc095fb4f5 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -4,9 +4,8 @@ from unittest.mock import patch from freezegun import freeze_time -from homeassistant.components import gdacs from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL -from homeassistant.components.gdacs.const import CONF_CATEGORIES +from homeassistant.components.gdacs.const import CONF_CATEGORIES, DOMAIN from homeassistant.components.gdacs.sensor import ( ATTR_CREATED, ATTR_LAST_UPDATE, @@ -73,7 +72,7 @@ async def test_setup(hass: HomeAssistant) -> None: CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL.seconds, } config_entry = MockConfigEntry( - domain=gdacs.DOMAIN, + domain=DOMAIN, title=f"{latitude}, {longitude}", data=entry_data, unique_id="my_very_unique_id", diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index bd4792f939d..254d4da5806 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -2,17 +2,136 @@ from __future__ import annotations -from homeassistant.components.generic_hygrostat import ( - DOMAIN as GENERIC_HYDROSTAT_DOMAIN, -) -from homeassistant.core import HomeAssistant +from unittest.mock import patch + +import pytest + +from homeassistant.components import generic_hygrostat +from homeassistant.components.generic_hygrostat import DOMAIN +from homeassistant.components.generic_hygrostat.config_flow import ConfigFlowHandler +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from .test_humidifier import ENT_SENSOR from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EE")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def switch_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a switch config entry.""" + switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) + return switch_config_entry + + +@pytest.fixture +def switch_device( + device_registry: dr.DeviceRegistry, switch_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a switch device.""" + return device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def switch_entity_entry( + entity_registry: er.EntityRegistry, + switch_config_entry: ConfigEntry, + switch_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a switch entity entry.""" + return entity_registry.async_get_or_create( + "switch", + "test", + "unique", + config_entry=switch_config_entry, + device_id=switch_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def generic_hygrostat_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, + switch_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a generic_hygrostat config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": switch_entity_entry.entity_id, + "name": "My generic hygrostat", + "target_sensor": sensor_entity_entry.entity_id, + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_device_cleaning( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -45,7 +164,7 @@ async def test_device_cleaning( # Configure the configuration entry for helper helper_config_entry = MockConfigEntry( data={}, - domain=GENERIC_HYDROSTAT_DOMAIN, + domain=DOMAIN, options={ "device_class": "humidifier", "dry_tolerance": 2.0, @@ -100,3 +219,302 @@ async def test_device_cleaning( assert len(devices_after_reload) == 1 assert devices_after_reload[0].id == source_device1_entry.id + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "expected_events"), + [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + expected_events: list[str], +) -> None: + """Test the generic_hygrostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check if the generic_hygrostat config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), + [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity removed from the source device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Remove the source entity from the device + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the generic_hygrostat config entry is removed from the device + source_device = device_registry.async_get(source_device.id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), + [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity is moved to another device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_entity_entry.config_entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + source_device_2 = device_registry.async_get(source_device_2.id) + assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Move the source entity to another device + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the generic_hygrostat config entry is moved to the other device + source_device = device_registry.async_get(source_device.id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device_2.config_entries + ) == helper_in_device + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + [ + ("switch.test_unique", "switch.new_entity_id", True, "humidifier"), + ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + new_entity_id: str, + helper_in_device: bool, + config_key: str, +) -> None: + """Test the source entity's entity ID is changed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id=new_entity_id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the generic_hygrostat config entry is updated with the new entity ID + assert generic_hygrostat_config_entry.options[config_key] == new_entity_id + + # Check that the helper config is still in the device + source_device = device_registry.async_get(source_device.id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == [] diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index addae2f684e..9131e3ffdd4 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -2,13 +2,134 @@ from __future__ import annotations +from unittest.mock import patch + +import pytest + +from homeassistant.components import generic_thermostat +from homeassistant.components.generic_thermostat.config_flow import ConfigFlowHandler from homeassistant.components.generic_thermostat.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EE")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def switch_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a switch config entry.""" + switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) + return switch_config_entry + + +@pytest.fixture +def switch_device( + device_registry: dr.DeviceRegistry, switch_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a switch device.""" + return device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def switch_entity_entry( + entity_registry: er.EntityRegistry, + switch_config_entry: ConfigEntry, + switch_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a switch entity entry.""" + return entity_registry.async_get_or_create( + "switch", + "test", + "unique", + config_entry=switch_config_entry, + device_id=switch_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def generic_thermostat_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, + switch_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a generic_thermostat config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": switch_entity_entry.entity_id, + "target_sensor": sensor_entity_entry.entity_id, + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_device_cleaning( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -96,3 +217,308 @@ async def test_device_cleaning( assert len(devices_after_reload) == 1 assert devices_after_reload[0].id == source_device1_entry.id + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "expected_events"), + [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + expected_events: list[str], +) -> None: + """Test the generic_thermostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check if the generic_thermostat config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), + [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity removed from the source device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Remove the source entity from the device + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the generic_thermostat config entry is removed from the device + source_device = device_registry.async_get(source_device.id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), + [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity is moved to another device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_entity_entry.config_entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + source_device_2 = device_registry.async_get(source_device_2.id) + assert ( + generic_thermostat_config_entry.entry_id not in source_device_2.config_entries + ) + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Move the source entity to another device + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the generic_thermostat config entry is moved to the other device + source_device = device_registry.async_get(source_device.id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert ( + generic_thermostat_config_entry.entry_id in source_device_2.config_entries + ) == helper_in_device + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + [ + ("switch.test_unique", "switch.new_entity_id", True, "heater"), + ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + new_entity_id: str, + helper_in_device: bool, + config_key: str, +) -> None: + """Test the source entity's entity ID is changed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id=new_entity_id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the generic_thermostat config entry is updated with the new entity ID + assert generic_thermostat_config_entry.options[config_key] == new_entity_id + + # Check that the helper config is still in the device + source_device = device_registry.async_get(source_device.id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == [] diff --git a/tests/components/geniushub/snapshots/test_binary_sensor.ambr b/tests/components/geniushub/snapshots/test_binary_sensor.ambr index c295ab8d10a..07f8ecb297d 100644 --- a/tests/components/geniushub/snapshots/test_binary_sensor.ambr +++ b/tests/components/geniushub/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Single Channel Receiver 22', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_22', diff --git a/tests/components/geniushub/snapshots/test_climate.ambr b/tests/components/geniushub/snapshots/test_climate.ambr index 8f897c84559..c80e54420e7 100644 --- a/tests/components/geniushub/snapshots/test_climate.ambr +++ b/tests/components/geniushub/snapshots/test_climate.ambr @@ -37,6 +37,7 @@ 'original_name': 'Bedroom', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_29', @@ -118,6 +119,7 @@ 'original_name': 'Ensuite', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_5', @@ -201,6 +203,7 @@ 'original_name': 'Guest room', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_7', @@ -284,6 +287,7 @@ 'original_name': 'Hall', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_2', @@ -367,6 +371,7 @@ 'original_name': 'Kitchen', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_3', @@ -449,6 +454,7 @@ 'original_name': 'Lounge', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_1', @@ -530,6 +536,7 @@ 'original_name': 'Study', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_30', diff --git a/tests/components/geniushub/snapshots/test_sensor.ambr b/tests/components/geniushub/snapshots/test_sensor.ambr index aaf3030d4a4..53594845b99 100644 --- a/tests/components/geniushub/snapshots/test_sensor.ambr +++ b/tests/components/geniushub/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'GeniusHub Errors', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_Errors', @@ -76,6 +77,7 @@ 'original_name': 'GeniusHub Information', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_Information', @@ -125,6 +127,7 @@ 'original_name': 'GeniusHub Warnings', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_Warnings', @@ -174,6 +177,7 @@ 'original_name': 'Radiator Valve 11', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_11', @@ -228,6 +232,7 @@ 'original_name': 'Radiator Valve 56', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_56', @@ -282,6 +287,7 @@ 'original_name': 'Radiator Valve 68', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_68', @@ -336,6 +342,7 @@ 'original_name': 'Radiator Valve 78', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_78', @@ -390,6 +397,7 @@ 'original_name': 'Radiator Valve 85', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_85', @@ -444,6 +452,7 @@ 'original_name': 'Radiator Valve 88', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_88', @@ -498,6 +507,7 @@ 'original_name': 'Radiator Valve 89', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_89', @@ -552,6 +562,7 @@ 'original_name': 'Radiator Valve 90', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_90', @@ -606,6 +617,7 @@ 'original_name': 'Room Sensor 16', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_16', @@ -662,6 +674,7 @@ 'original_name': 'Room Sensor 17', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_17', @@ -718,6 +731,7 @@ 'original_name': 'Room Sensor 18', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_18', @@ -774,6 +788,7 @@ 'original_name': 'Room Sensor 20', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_20', @@ -830,6 +845,7 @@ 'original_name': 'Room Sensor 21', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_21', @@ -886,6 +902,7 @@ 'original_name': 'Room Sensor 50', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_50', @@ -942,6 +959,7 @@ 'original_name': 'Room Sensor 53', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_53', diff --git a/tests/components/geniushub/snapshots/test_switch.ambr b/tests/components/geniushub/snapshots/test_switch.ambr index cc0451b4e94..f20717182c0 100644 --- a/tests/components/geniushub/snapshots/test_switch.ambr +++ b/tests/components/geniushub/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Bedroom Socket', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_27', @@ -83,6 +84,7 @@ 'original_name': 'Kitchen Socket', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_28', @@ -139,6 +141,7 @@ 'original_name': 'Study Socket', 'platform': 'geniushub', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_32', diff --git a/tests/components/geniushub/test_binary_sensor.py b/tests/components/geniushub/test_binary_sensor.py index 682929eb696..6edeb317a55 100644 --- a/tests/components/geniushub/test_binary_sensor.py +++ b/tests/components/geniushub/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/geniushub/test_climate.py b/tests/components/geniushub/test_climate.py index d14e57b9552..d116f862b55 100644 --- a/tests/components/geniushub/test_climate.py +++ b/tests/components/geniushub/test_climate.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/geniushub/test_sensor.py b/tests/components/geniushub/test_sensor.py index a75329ca7fc..6e3af621bcc 100644 --- a/tests/components/geniushub/test_sensor.py +++ b/tests/components/geniushub/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/geniushub/test_switch.py b/tests/components/geniushub/test_switch.py index 0e88562e381..905c32e0c35 100644 --- a/tests/components/geniushub/test_switch.py +++ b/tests/components/geniushub/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 33740397868..0e8752c97ec 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -318,12 +318,11 @@ async def test_load_unload_entry( state_1 = hass.states.get(f"device_tracker.{device_name}") assert state_1.state == STATE_HOME - assert len(hass.data[DOMAIN]["devices"]) == 1 entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(entry.runtime_data) == 1 assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[DOMAIN]["devices"]) == 0 assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/geonetnz_quakes/test_diagnostics.py b/tests/components/geonetnz_quakes/test_diagnostics.py index db5e1300768..ffe570cb269 100644 --- a/tests/components/geonetnz_quakes/test_diagnostics.py +++ b/tests/components/geonetnz_quakes/test_diagnostics.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index fd8ba81fca7..7373b207bab 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -5,9 +5,8 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory -from homeassistant.components import geonetnz_quakes from homeassistant.components.geo_location import ATTR_SOURCE -from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED +from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.components.geonetnz_quakes.geo_location import ( ATTR_DEPTH, ATTR_EXTERNAL_ID, @@ -38,7 +37,7 @@ from . import _generate_mock_feed_entry from tests.common import async_fire_time_changed -CONFIG = {geonetnz_quakes.DOMAIN: {CONF_RADIUS: 200}} +CONFIG = {DOMAIN: {CONF_RADIUS: 200}} async def test_setup( @@ -74,7 +73,7 @@ async def test_setup( freezer.move_to(utcnow) with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update: mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] - assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) + assert await async_setup_component(hass, DOMAIN, CONFIG) await hass.async_block_till_done() # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -188,7 +187,7 @@ async def test_setup_imperial( patch("aio_geojson_client.feed.GeoJsonFeed.last_timestamp", create=True), ): mock_feed_update.return_value = "OK", [mock_entry_1] - assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) + assert await async_setup_component(hass, DOMAIN, CONFIG) await hass.async_block_till_done() # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -201,10 +200,7 @@ async def test_setup_imperial( ) # Test conversion of 200 miles to kilometers. - feeds = hass.data[DOMAIN][FEED] - assert feeds is not None - assert len(feeds) == 1 - manager = list(feeds.values())[0] + manager = hass.config_entries.async_loaded_entries(DOMAIN)[0].runtime_data # Ensure that the filter value in km is correctly set. assert manager._feed_manager._feed._filter_radius == 321.8688 diff --git a/tests/components/geonetnz_quakes/test_init.py b/tests/components/geonetnz_quakes/test_init.py index 6730fa53ece..fd334fa57ee 100644 --- a/tests/components/geonetnz_quakes/test_init.py +++ b/tests/components/geonetnz_quakes/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import patch -from homeassistant.components.geonetnz_quakes import DOMAIN, FEED from homeassistant.core import HomeAssistant @@ -16,8 +15,7 @@ async def test_component_unload_config_entry(hass: HomeAssistant, config_entry) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None + # Unload config entry. assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None diff --git a/tests/components/geonetnz_volcano/test_init.py b/tests/components/geonetnz_volcano/test_init.py index fe113434dc6..49b4af2abec 100644 --- a/tests/components/geonetnz_volcano/test_init.py +++ b/tests/components/geonetnz_volcano/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, patch -from homeassistant.components.geonetnz_volcano import DOMAIN, FEED from homeassistant.core import HomeAssistant @@ -17,8 +16,7 @@ async def test_component_unload_config_entry(hass: HomeAssistant, config_entry) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None + # Unload config entry. assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None diff --git a/tests/components/gios/snapshots/test_sensor.ambr b/tests/components/gios/snapshots/test_sensor.ambr index ab8a2359d0c..fd74cc222c8 100644 --- a/tests/components/gios/snapshots/test_sensor.ambr +++ b/tests/components/gios/snapshots/test_sensor.ambr @@ -36,6 +36,7 @@ 'original_name': 'Air quality index', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'aqi', 'unique_id': '123-aqi', @@ -98,6 +99,7 @@ 'original_name': 'Benzene', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'c6h6', 'unique_id': '123-c6h6', @@ -153,6 +155,7 @@ 'original_name': 'Carbon monoxide', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co', 'unique_id': '123-co', @@ -208,6 +211,7 @@ 'original_name': 'Nitrogen dioxide', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-no2', @@ -268,6 +272,7 @@ 'original_name': 'Nitrogen dioxide index', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'no2_index', 'unique_id': '123-no2-index', @@ -330,6 +335,7 @@ 'original_name': 'Ozone', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-o3', @@ -390,6 +396,7 @@ 'original_name': 'Ozone index', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'o3_index', 'unique_id': '123-o3-index', @@ -452,6 +459,7 @@ 'original_name': 'PM10', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-pm10', @@ -512,6 +520,7 @@ 'original_name': 'PM10 index', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pm10_index', 'unique_id': '123-pm10-index', @@ -574,6 +583,7 @@ 'original_name': 'PM2.5', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-pm25', @@ -634,6 +644,7 @@ 'original_name': 'PM2.5 index', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pm25_index', 'unique_id': '123-pm25-index', @@ -696,6 +707,7 @@ 'original_name': 'Sulphur dioxide', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123-so2', @@ -756,6 +768,7 @@ 'original_name': 'Sulphur dioxide index', 'platform': 'gios', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'so2_index', 'unique_id': '123-so2-index', diff --git a/tests/components/gios/test_diagnostics.py b/tests/components/gios/test_diagnostics.py index a965e5550df..cc3df9e3593 100644 --- a/tests/components/gios/test_diagnostics.py +++ b/tests/components/gios/test_diagnostics.py @@ -1,6 +1,6 @@ """Test GIOS diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index d9096916106..fd343d16525 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -6,7 +6,7 @@ import json from unittest.mock import patch from gios import ApiError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.gios.const import DOMAIN from homeassistant.components.sensor import DOMAIN as PLATFORM diff --git a/tests/components/glances/snapshots/test_sensor.ambr b/tests/components/glances/snapshots/test_sensor.ambr index baac4c5b056..40dd1a00cd1 100644 --- a/tests/components/glances/snapshots/test_sensor.ambr +++ b/tests/components/glances/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Containers active', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'container_active', 'unique_id': 'test--docker_active', @@ -79,6 +80,7 @@ 'original_name': 'Containers CPU usage', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'container_cpu_usage', 'unique_id': 'test--docker_cpu_use', @@ -124,12 +126,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Containers memory used', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'container_memory_used', 'unique_id': 'test--docker_memory_use', @@ -176,12 +182,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'cpu_thermal 1 temperature', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': 'test-cpu_thermal 1-temperature_core', @@ -228,6 +238,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -237,6 +250,7 @@ 'original_name': 'dummy0 RX', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_rx', 'unique_id': 'test-dummy0-rx', @@ -256,7 +270,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.000000', + 'state': '0.0', }) # --- # name: test_sensor_states[sensor.0_0_0_0_dummy0_tx-entry] @@ -283,6 +297,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -292,6 +309,7 @@ 'original_name': 'dummy0 TX', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_tx', 'unique_id': 'test-dummy0-tx', @@ -311,7 +329,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.000000', + 'state': '0.0', }) # --- # name: test_sensor_states[sensor.0_0_0_0_err_temp_temperature-entry] @@ -338,12 +356,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'err_temp temperature', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': 'test-err_temp-temperature_hdd', @@ -390,6 +412,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -399,6 +424,7 @@ 'original_name': 'eth0 RX', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_rx', 'unique_id': 'test-eth0-rx', @@ -418,7 +444,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.03162', + 'state': '0.031624', }) # --- # name: test_sensor_states[sensor.0_0_0_0_eth0_tx-entry] @@ -445,6 +471,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -454,6 +483,7 @@ 'original_name': 'eth0 TX', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_tx', 'unique_id': 'test-eth0-tx', @@ -500,6 +530,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -509,6 +542,7 @@ 'original_name': 'lo RX', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_rx', 'unique_id': 'test-lo-rx', @@ -528,7 +562,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.06117', + 'state': '0.061168', }) # --- # name: test_sensor_states[sensor.0_0_0_0_lo_tx-entry] @@ -555,6 +589,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -564,6 +601,7 @@ 'original_name': 'lo TX', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'network_tx', 'unique_id': 'test-lo-tx', @@ -583,7 +621,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.06117', + 'state': '0.061168', }) # --- # name: test_sensor_states[sensor.0_0_0_0_md1_available-entry] @@ -616,6 +654,7 @@ 'original_name': 'md1 available', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raid_available', 'unique_id': 'test-md1-available', @@ -666,6 +705,7 @@ 'original_name': 'md1 used', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raid_used', 'unique_id': 'test-md1-used', @@ -716,6 +756,7 @@ 'original_name': 'md3 available', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raid_available', 'unique_id': 'test-md3-available', @@ -766,6 +807,7 @@ 'original_name': 'md3 used', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raid_used', 'unique_id': 'test-md3-used', @@ -810,12 +852,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': '/media disk free', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_free', 'unique_id': 'test-/media-disk_free', @@ -868,6 +914,7 @@ 'original_name': '/media disk usage', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_usage', 'unique_id': 'test-/media-disk_use_percent', @@ -913,12 +960,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': '/media disk used', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_used', 'unique_id': 'test-/media-disk_use', @@ -965,12 +1016,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Memory free', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'memory_free', 'unique_id': 'test--memory_free', @@ -1023,6 +1078,7 @@ 'original_name': 'Memory usage', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'memory_usage', 'unique_id': 'test--memory_use_percent', @@ -1068,12 +1124,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Memory use', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'memory_use', 'unique_id': 'test--memory_use', @@ -1120,12 +1180,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'na_temp temperature', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': 'test-na_temp-temperature_hdd', @@ -1178,6 +1242,7 @@ 'original_name': 'NVIDIA GeForce RTX 3080 (GPU 0) fan speed', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan_speed', 'unique_id': 'test-NVIDIA GeForce RTX 3080 (GPU 0)-fan_speed', @@ -1229,6 +1294,7 @@ 'original_name': 'NVIDIA GeForce RTX 3080 (GPU 0) memory usage', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gpu_memory_usage', 'unique_id': 'test-NVIDIA GeForce RTX 3080 (GPU 0)-mem', @@ -1283,6 +1349,7 @@ 'original_name': 'NVIDIA GeForce RTX 3080 (GPU 0) processor usage', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gpu_processor_usage', 'unique_id': 'test-NVIDIA GeForce RTX 3080 (GPU 0)-proc', @@ -1328,12 +1395,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'NVIDIA GeForce RTX 3080 (GPU 0) temperature', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': 'test-NVIDIA GeForce RTX 3080 (GPU 0)-temperature', @@ -1380,6 +1451,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1389,6 +1463,7 @@ 'original_name': 'nvme0n1 disk read', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'diskio_read', 'unique_id': 'test-nvme0n1-read', @@ -1408,7 +1483,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.184320', + 'state': '0.18432', }) # --- # name: test_sensor_states[sensor.0_0_0_0_nvme0n1_disk_write-entry] @@ -1435,6 +1510,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1444,6 +1522,7 @@ 'original_name': 'nvme0n1 disk write', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'diskio_write', 'unique_id': 'test-nvme0n1-write', @@ -1490,6 +1569,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1499,6 +1581,7 @@ 'original_name': 'sda disk read', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'diskio_read', 'unique_id': 'test-sda-read', @@ -1545,6 +1628,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1554,6 +1640,7 @@ 'original_name': 'sda disk write', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'diskio_write', 'unique_id': 'test-sda-write', @@ -1600,12 +1687,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': '/ssl disk free', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_free', 'unique_id': 'test-/ssl-disk_free', @@ -1658,6 +1749,7 @@ 'original_name': '/ssl disk usage', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_usage', 'unique_id': 'test-/ssl-disk_use_percent', @@ -1703,12 +1795,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': '/ssl disk used', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_used', 'unique_id': 'test-/ssl-disk_use', @@ -1759,6 +1855,7 @@ 'original_name': 'Uptime', 'platform': 'glances', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uptime', 'unique_id': 'test--uptime', diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 8e0367a712c..71bb689f3ff 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.glances.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE diff --git a/tests/components/goalzero/test_init.py b/tests/components/goalzero/test_init.py index 4817be1ce35..95f468a93fe 100644 --- a/tests/components/goalzero/test_init.py +++ b/tests/components/goalzero/test_init.py @@ -12,18 +12,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.util import dt as dt_util -from . import CONF_DATA, async_init_integration, create_entry, create_mocked_yeti +from . import CONF_DATA, async_init_integration, create_entry from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker -async def test_setup_config_and_unload(hass: HomeAssistant) -> None: +async def test_setup_config_and_unload( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Test Goal Zero setup and unload.""" - entry = create_entry(hass) - mocked_yeti = await create_mocked_yeti() - with patch("homeassistant.components.goalzero.Yeti", return_value=mocked_yeti): - await hass.config_entries.async_setup(entry.entry_id) + entry = await async_init_integration(hass, aioclient_mock) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -37,14 +36,12 @@ async def test_setup_config_and_unload(hass: HomeAssistant) -> None: async def test_setup_config_entry_incorrectly_formatted_mac( - hass: HomeAssistant, + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test the mac address formatting is corrected.""" - entry = create_entry(hass) + entry = await async_init_integration(hass, aioclient_mock, skip_setup=True) hass.config_entries.async_update_entry(entry, unique_id="AABBCCDDEEFF") - mocked_yeti = await create_mocked_yeti() - with patch("homeassistant.components.goalzero.Yeti", return_value=mocked_yeti): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/goodwe/test_diagnostics.py b/tests/components/goodwe/test_diagnostics.py index 0a997edc594..fa90889e75e 100644 --- a/tests/components/goodwe/test_diagnostics.py +++ b/tests/components/goodwe/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.goodwe import CONF_MODEL_FAMILY, DOMAIN diff --git a/tests/components/google_assistant/test_button.py b/tests/components/google_assistant/test_button.py index 6fdb94a5610..9e60576b3e6 100644 --- a/tests/components/google_assistant/test_button.py +++ b/tests/components/google_assistant/test_button.py @@ -29,7 +29,7 @@ async def test_sync_button(hass: HomeAssistant, hass_owner_user: MockUser) -> No assert state config_entry = hass.config_entries.async_entries("google_assistant")[0] - google_config: ga.GoogleConfig = hass.data[ga.DOMAIN][config_entry.entry_id] + google_config: ga.GoogleConfig = config_entry.runtime_data with patch.object(google_config, "async_sync_entities") as mock_sync_entities: mock_sync_entities.return_value = 200 diff --git a/tests/components/google_assistant/test_diagnostics.py b/tests/components/google_assistant/test_diagnostics.py index 1d68079563c..b75654edd1b 100644 --- a/tests/components/google_assistant/test_diagnostics.py +++ b/tests/components/google_assistant/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant import setup diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index f986497ed29..9bb08c802c2 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -6,6 +6,7 @@ import time from unittest.mock import call, patch import aiohttp +from grpc import RpcError import pytest from homeassistant.components import conversation @@ -13,6 +14,7 @@ from homeassistant.components.google_assistant_sdk import DOMAIN from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUAGE_CODES from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -231,11 +233,34 @@ async def test_send_text_command_expired_token_refresh_failure( {"command": "turn on tv"}, blocking=True, ) - await hass.async_block_till_done() assert any(entry.async_get_active_flows(hass, {"reauth"})) == requires_reauth +async def test_send_text_command_grpc_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test service call send_text_command when RpcError is raised.""" + await setup_integration() + + command = "turn on home assistant unsupported device" + with ( + patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist", + side_effect=RpcError(), + ) as mock_assist_call, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + DOMAIN, + "send_text_command", + {"command": command}, + blocking=True, + ) + mock_assist_call.assert_called_once_with(command) + + async def test_send_text_command_media_player( hass: HomeAssistant, setup_integration: ComponentSetup, diff --git a/tests/components/google_assistant_sdk/test_notify.py b/tests/components/google_assistant_sdk/test_notify.py index 266846b17e1..ca4162c9e7a 100644 --- a/tests/components/google_assistant_sdk/test_notify.py +++ b/tests/components/google_assistant_sdk/test_notify.py @@ -2,6 +2,7 @@ from unittest.mock import call, patch +from grpc import RpcError import pytest from homeassistant.components import notify @@ -9,6 +10,7 @@ from homeassistant.components.google_assistant_sdk import DOMAIN from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUAGE_CODES from homeassistant.components.google_assistant_sdk.notify import broadcast_commands from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import ComponentSetup, ExpectedCredentials @@ -45,8 +47,8 @@ async def test_broadcast_no_targets( notify.DOMAIN, DOMAIN, {notify.ATTR_MESSAGE: message}, + blocking=True, ) - await hass.async_block_till_done() mock_text_assistant.assert_called_once_with( ExpectedCredentials(), language_code, audio_out=False ) @@ -54,6 +56,30 @@ async def test_broadcast_no_targets( mock_text_assistant.assert_has_calls([call().__enter__().assist(expected_command)]) +async def test_broadcast_grpc_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test broadcast handling when RpcError is raised.""" + await setup_integration() + + with ( + patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist", + side_effect=RpcError(), + ) as mock_assist_call, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + notify.DOMAIN, + DOMAIN, + {notify.ATTR_MESSAGE: "Dinner is served"}, + blocking=True, + ) + + mock_assist_call.assert_called_once_with("broadcast Dinner is served") + + @pytest.mark.parametrize( ("language_code", "message", "target", "expected_command"), [ @@ -103,8 +129,8 @@ async def test_broadcast_one_target( notify.DOMAIN, DOMAIN, {notify.ATTR_MESSAGE: message, notify.ATTR_TARGET: [target]}, + blocking=True, ) - await hass.async_block_till_done() mock_assist_call.assert_called_once_with(expected_command) @@ -127,8 +153,8 @@ async def test_broadcast_two_targets( notify.DOMAIN, DOMAIN, {notify.ATTR_MESSAGE: message, notify.ATTR_TARGET: [target1, target2]}, + blocking=True, ) - await hass.async_block_till_done() mock_assist_call.assert_has_calls( [call(expected_command1), call(expected_command2)] ) @@ -148,8 +174,8 @@ async def test_broadcast_empty_message( notify.DOMAIN, DOMAIN, {notify.ATTR_MESSAGE: ""}, + blocking=True, ) - await hass.async_block_till_done() mock_assist_call.assert_not_called() diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 9cf86a280bd..b8e37d0f3b8 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -49,11 +49,13 @@ TEST_AGENT_BACKUP_RESULT = { "database_included": True, "date": "2025-01-01T01:23:45.678Z", "extra_metadata": {"with_automatic_settings": False}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "failed_agent_ids": [], "with_automatic_settings": None, } diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py index fbf9ee545db..18b3c8e07f0 100644 --- a/tests/components/google_generative_ai_conversation/__init__.py +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -2,10 +2,10 @@ from unittest.mock import Mock -from google.genai.errors import ClientError +from google.genai.errors import APIError, ClientError import httpx -CLIENT_ERROR_500 = ClientError( +API_ERROR_500 = APIError( 500, Mock( __class__=httpx.Response, @@ -17,6 +17,18 @@ CLIENT_ERROR_500 = ClientError( ), ), ) +CLIENT_ERROR_BAD_REQUEST = ClientError( + 400, + Mock( + __class__=httpx.Response, + json=Mock( + return_value={ + "message": "Bad Request", + "status": "invalid-argument", + } + ), + ), +) CLIENT_ERROR_API_KEY_INVALID = ClientError( 400, Mock( diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr deleted file mode 100644 index ce257e61d53..00000000000 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ /dev/null @@ -1,100 +0,0 @@ -# serializer version: 1 -# name: test_function_call - list([ - tuple( - '', - tuple( - ), - dict({ - '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', - }), - ), - tuple( - '().send_message', - tuple( - ), - dict({ - 'message': 'Please call the test function', - }), - ), - tuple( - '().send_message', - tuple( - ), - dict({ - 'message': list([ - Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), - ]), - }), - ), - ]) -# --- -# name: test_function_call_without_parameters - list([ - tuple( - '', - tuple( - ), - dict({ - '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', - }), - ), - tuple( - '().send_message', - tuple( - ), - dict({ - 'message': 'Please call the test function', - }), - ), - tuple( - '().send_message', - tuple( - ), - dict({ - 'message': list([ - Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), - ]), - }), - ), - ]) -# --- -# name: test_use_google_search - list([ - tuple( - '', - tuple( - ), - dict({ - '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', - }), - ), - tuple( - '().send_message', - tuple( - ), - dict({ - 'message': 'Please call the test function', - }), - ), - tuple( - '().send_message', - tuple( - ), - dict({ - 'message': list([ - Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), - ]), - }), - ), - ]) -# --- 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 13063580c95..4234355cb5b 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -34,7 +34,7 @@ from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID +from . import API_ERROR_500, CLIENT_ERROR_API_KEY_INVALID from tests.common import MockConfigEntry @@ -339,7 +339,7 @@ async def test_options_switching( ("side_effect", "error"), [ ( - CLIENT_ERROR_500, + API_ERROR_500, "cannot_connect", ), ( diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 75cb308d5de..2d1a46393fd 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -1,16 +1,14 @@ """Tests for the Google Generative AI Conversation integration conversation platform.""" -from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch from freezegun import freeze_time -from google.genai.types import FunctionCall +from google.genai.types import GenerateContentResponse import pytest -from syrupy.assertion import SnapshotAssertion -import voluptuous as vol from homeassistant.components import conversation -from homeassistant.components.conversation import UserContent, async_get_chat_log, trace +from homeassistant.components.conversation import UserContent from homeassistant.components.google_generative_ai_conversation.conversation import ( ERROR_GETTING_RESPONSE, _escape_decode, @@ -18,12 +16,15 @@ from homeassistant.components.google_generative_ai_conversation.conversation imp ) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, intent, llm +from homeassistant.helpers import intent -from . import CLIENT_ERROR_500 +from . import API_ERROR_500, CLIENT_ERROR_BAD_REQUEST from tests.common import MockConfigEntry +from tests.components.conversation import ( + MockChatLog, + mock_chat_log, # noqa: F401 +) @pytest.fixture(autouse=True) @@ -40,396 +41,44 @@ def mock_ulid_tools(): yield -@patch( - "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" +@pytest.fixture +def mock_send_message_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(stream): + for value in stream: + yield value + + with patch( + "google.genai.chats.AsyncChat.send_message_stream", + AsyncMock(), + ) as mock_send_message_stream: + mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( + mock_send_message_stream.return_value.pop(0) + ) + + yield mock_send_message_stream + + +@pytest.mark.parametrize( + ("error"), + [ + (API_ERROR_500,), + (CLIENT_ERROR_BAD_REQUEST,), + ], ) -@pytest.mark.usefixtures("mock_init_component") -@pytest.mark.usefixtures("mock_ulid_tools") -async def test_function_call( - mock_get_tools, - hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Test function calling.""" - agent_id = "conversation.google_generative_ai_conversation" - context = Context() - - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema( - { - vol.Optional("param1", description="Test parameters"): [ - vol.All(str, vol.Lower) - ], - vol.Optional("param2"): vol.Any(float, int), - vol.Optional("param3"): dict, - } - ) - - mock_get_tools.return_value = [mock_tool] - - with patch("google.genai.chats.AsyncChats.create") as mock_create: - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat - chat_response = Mock(prompt_feedback=None) - mock_chat.return_value = chat_response - mock_part = Mock() - mock_part.text = "" - mock_part.function_call = FunctionCall( - name="test_tool", - args={ - "param1": ["test_value", "param1\\'s value"], - "param2": 2.7, - }, - ) - - def tool_call( - hass: HomeAssistant, tool_input: llm.ToolInput, tool_context: llm.LLMContext - ) -> dict[str, Any]: - mock_part.function_call = None - mock_part.text = "Hi there!" - return {"result": "Test response"} - - mock_tool.async_call.side_effect = tool_call - chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] - result = await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - device_id="test_device", - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] - assert len(mock_tool_response_parts) == 1 - assert mock_tool_response_parts[0].model_dump() == { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "result": "Test response", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, - } - - mock_tool.async_call.assert_awaited_once_with( - hass, - llm.ToolInput( - id="mock-tool-call", - tool_name="test_tool", - tool_args={ - "param1": ["test_value", "param1's value"], - "param2": 2.7, - }, - ), - llm.LLMContext( - platform="google_generative_ai_conversation", - context=context, - user_prompt="Please call the test function", - language="en", - assistant="conversation", - device_id="test_device", - ), - ) - assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot - - # Test conversating tracing - traces = trace.async_get_traces() - assert traces - last_trace = traces[-1].as_dict() - trace_events = last_trace.get("events", []) - assert [event["event_type"] for event in trace_events] == [ - trace.ConversationTraceEventType.ASYNC_PROCESS, - trace.ConversationTraceEventType.AGENT_DETAIL, # prompt and tools - trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response - trace.ConversationTraceEventType.TOOL_CALL, - trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response - ] - # AGENT_DETAIL event contains the raw prompt passed to the model - detail_event = trace_events[1] - assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] - assert [ - p["tool_name"] for p in detail_event["data"]["messages"][2]["tool_calls"] - ] == ["test_tool"] - - detail_event = trace_events[2] - assert set(detail_event["data"]["stats"].keys()) == { - "input_tokens", - "cached_input_tokens", - "output_tokens", - } - - -@patch( - "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" -) -@pytest.mark.usefixtures("mock_init_component") -@pytest.mark.usefixtures("mock_ulid_tools") -async def test_use_google_search( - mock_get_tools, - hass: HomeAssistant, - mock_config_entry_with_google_search: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Test function calling.""" - agent_id = "conversation.google_generative_ai_conversation" - context = Context() - - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema( - { - vol.Optional("param1", description="Test parameters"): [ - vol.All(str, vol.Lower) - ], - vol.Optional("param2"): vol.Any(float, int), - vol.Optional("param3"): dict, - } - ) - - mock_get_tools.return_value = [mock_tool] - - with patch("google.genai.chats.AsyncChats.create") as mock_create: - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat - chat_response = Mock(prompt_feedback=None) - mock_chat.return_value = chat_response - mock_part = Mock() - mock_part.text = "" - mock_part.function_call = FunctionCall( - name="test_tool", - args={ - "param1": ["test_value", "param1\\'s value"], - "param2": 2.7, - }, - ) - - def tool_call( - hass: HomeAssistant, tool_input: llm.ToolInput, tool_context: llm.LLMContext - ) -> dict[str, Any]: - mock_part.function_call = None - mock_part.text = "Hi there!" - return {"result": "Test response"} - - mock_tool.async_call.side_effect = tool_call - chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] - await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - device_id="test_device", - ) - - assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot - - -@patch( - "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" -) -@pytest.mark.usefixtures("mock_init_component") -async def test_function_call_without_parameters( - mock_get_tools, - hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Test function calling without parameters.""" - agent_id = "conversation.google_generative_ai_conversation" - context = Context() - - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema({}) - - mock_get_tools.return_value = [mock_tool] - - with patch("google.genai.chats.AsyncChats.create") as mock_create: - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat - chat_response = Mock(prompt_feedback=None) - mock_chat.return_value = chat_response - mock_part = Mock() - mock_part.text = "" - mock_part.function_call = FunctionCall(name="test_tool", args={}) - - def tool_call( - hass: HomeAssistant, tool_input: llm.ToolInput, tool_context: llm.LLMContext - ) -> dict[str, Any]: - mock_part.function_call = None - mock_part.text = "Hi there!" - return {"result": "Test response"} - - mock_tool.async_call.side_effect = tool_call - chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] - result = await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - device_id="test_device", - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] - assert len(mock_tool_response_parts) == 1 - assert mock_tool_response_parts[0].model_dump() == { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "result": "Test response", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, - } - - mock_tool.async_call.assert_awaited_once_with( - hass, - llm.ToolInput( - id="mock-tool-call", - tool_name="test_tool", - tool_args={}, - ), - llm.LLMContext( - platform="google_generative_ai_conversation", - context=context, - user_prompt="Please call the test function", - language="en", - assistant="conversation", - device_id="test_device", - ), - ) - assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot - - -@patch( - "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" -) -@pytest.mark.usefixtures("mock_init_component") -async def test_function_exception( - mock_get_tools, - hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, -) -> None: - """Test exception in function calling.""" - agent_id = "conversation.google_generative_ai_conversation" - context = Context() - - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema( - { - vol.Optional("param1", description="Test parameters"): vol.All( - vol.Coerce(int), vol.Range(0, 100) - ) - } - ) - - mock_get_tools.return_value = [mock_tool] - - with patch("google.genai.chats.AsyncChats.create") as mock_create: - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat - chat_response = Mock(prompt_feedback=None) - mock_chat.return_value = chat_response - mock_part = Mock() - mock_part.text = "" - mock_part.function_call = FunctionCall(name="test_tool", args={"param1": 1}) - - def tool_call( - hass: HomeAssistant, tool_input: llm.ToolInput, tool_context: llm.LLMContext - ) -> dict[str, Any]: - mock_part.function_call = None - mock_part.text = "Hi there!" - raise HomeAssistantError("Test tool exception") - - mock_tool.async_call.side_effect = tool_call - chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] - result = await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - device_id="test_device", - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] - assert len(mock_tool_response_parts) == 1 - assert mock_tool_response_parts[0].model_dump() == { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "error": "HomeAssistantError", - "error_text": "Test tool exception", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, - } - mock_tool.async_call.assert_awaited_once_with( - hass, - llm.ToolInput( - id="mock-tool-call", - tool_name="test_tool", - tool_args={"param1": 1}, - ), - llm.LLMContext( - platform="google_generative_ai_conversation", - context=context, - user_prompt="Please call the test function", - language="en", - assistant="conversation", - device_id="test_device", - ), - ) - - -@pytest.mark.usefixtures("mock_init_component") async def test_error_handling( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + error, ) -> None: """Test that client errors are caught.""" - with patch("google.genai.chats.AsyncChats.create") as mock_create: - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat - mock_chat.side_effect = CLIENT_ERROR_500 + with patch( + "google.genai.chats.AsyncChat.send_message_stream", + new_callable=AsyncMock, + side_effect=error, + ): result = await conversation.async_converse( hass, "hello", @@ -437,32 +86,251 @@ async def test_error_handling( Context(), agent_id="conversation.google_generative_ai_conversation", ) - assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result - assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Sorry, I had a problem talking to Google Generative AI: 500 internal-error. {'message': 'Internal Server Error', 'status': 'internal-error'}" + assert ( + result.response.as_dict()["speech"]["plain"]["speech"] == ERROR_GETTING_RESPONSE ) +@pytest.mark.usefixtures("mock_init_component") +@pytest.mark.usefixtures("mock_ulid_tools") +async def test_function_call( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, +) -> None: + """Test function calling.""" + agent_id = "conversation.google_generative_ai_conversation" + context = Context() + + messages = [ + # Function call stream + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "text": "Hi there!", + } + ], + "role": "model", + } + } + ] + ), + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "function_call": { + "name": "test_tool", + "args": { + "param1": [ + "test_value", + "param1\\'s value", + ], + "param2": 2.7, + }, + }, + } + ], + "role": "model", + }, + "finish_reason": "STOP", + } + ] + ), + ], + # Messages after function response is sent + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "text": "I've called the ", + } + ], + "role": "model", + }, + } + ], + ), + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "text": "test function with the provided parameters.", + } + ], + "role": "model", + }, + "finish_reason": "STOP", + } + ], + ), + ], + ] + + mock_send_message_stream.return_value = messages + + mock_chat_log.mock_tool_results( + { + "mock-tool-call": {"result": "Test response"}, + } + ) + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.as_dict()["speech"]["plain"]["speech"] + == "I've called the test function with the provided parameters." + ) + mock_tool_response_parts = mock_send_message_stream.mock_calls[1][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "result": "Test response", + }, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, + } + + +@pytest.mark.usefixtures("mock_init_component") +@pytest.mark.usefixtures("mock_ulid_tools") +async def test_google_search_tool_is_sent( + hass: HomeAssistant, + mock_config_entry_with_google_search: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, +) -> None: + """Test if the Google Search tool is sent to the model.""" + agent_id = "conversation.google_generative_ai_conversation" + context = Context() + + messages = [ + # Messages from the model which contain the google search answer (the usage of the Google Search tool is server side) + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "text": "The last winner ", + } + ], + "role": "model", + }, + } + ], + ), + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + {"text": "of the 2024 FIFA World Cup was Argentina."} + ], + "role": "model", + }, + "finish_reason": "STOP", + } + ], + ), + ], + ] + + mock_send_message_stream.return_value = messages + + with patch( + "google.genai.chats.AsyncChats.create", return_value=AsyncMock() + ) as mock_create: + mock_create.return_value.send_message_stream = mock_send_message_stream + result = await conversation.async_converse( + hass, + "Who won the 2024 FIFA World Cup?", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.as_dict()["speech"]["plain"]["speech"] + == "The last winner of the 2024 FIFA World Cup was Argentina." + ) + assert mock_create.mock_calls[0][2]["config"].tools[-1].google_search is not None + + @pytest.mark.usefixtures("mock_init_component") async def test_blocked_response( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, ) -> None: """Test blocked response.""" - with patch("google.genai.chats.AsyncChats.create") as mock_create: - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat - chat_response = Mock(prompt_feedback=Mock(block_reason_message="SAFETY")) - mock_chat.return_value = chat_response + agent_id = "conversation.google_generative_ai_conversation" + context = Context() - result = await conversation.async_converse( - hass, - "hello", - None, - Context(), - agent_id="conversation.google_generative_ai_conversation", - ) + messages = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "text": "I've called the ", + } + ], + "role": "model", + }, + } + ], + ), + GenerateContentResponse(prompt_feedback={"block_reason_message": "SAFETY"}), + ], + ] + + mock_send_message_stream.return_value = messages + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", + ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result @@ -473,23 +341,41 @@ async def test_blocked_response( @pytest.mark.usefixtures("mock_init_component") async def test_empty_response( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, ) -> None: """Test empty response.""" - with patch("google.genai.chats.AsyncChats.create") as mock_create: - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat - chat_response = Mock(prompt_feedback=None) - mock_chat.return_value = chat_response - chat_response.candidates = [Mock(content=Mock(parts=[]))] - result = await conversation.async_converse( - hass, - "hello", - None, - Context(), - agent_id="conversation.google_generative_ai_conversation", - ) + agent_id = "conversation.google_generative_ai_conversation" + context = Context() + + messages = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [], + "role": "model", + }, + } + ], + ), + ], + ] + + mock_send_message_stream.return_value = messages + + result = await conversation.async_converse( + hass, + "Hello", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", + ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( @@ -499,27 +385,36 @@ async def test_empty_response( @pytest.mark.usefixtures("mock_init_component") async def test_none_response( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, ) -> None: - """Test empty response.""" - with patch("google.genai.chats.AsyncChats.create") as mock_create: - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat - chat_response = Mock(prompt_feedback=None) - mock_chat.return_value = chat_response - chat_response.candidates = None - result = await conversation.async_converse( - hass, - "hello", - None, - Context(), - agent_id="conversation.google_generative_ai_conversation", - ) + """Test None response.""" + agent_id = "conversation.google_generative_ai_conversation" + context = Context() + + messages = [ + [ + GenerateContentResponse(), + ], + ] + + mock_send_message_stream.return_value = messages + + result = await conversation.async_converse( + hass, + "Hello", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", + ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - ERROR_GETTING_RESPONSE + "The message got blocked due to content violations, reason: unknown" ) @@ -712,69 +607,109 @@ async def test_format_schema(openapi, genai_schema) -> None: @pytest.mark.usefixtures("mock_init_component") async def test_empty_content_in_chat_history( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, ) -> None: """Tests that in case of an empty entry in the chat history the google API will receive an injected space sign instead.""" - with ( - patch("google.genai.chats.AsyncChats.create") as mock_create, - chat_session.async_get_chat_session(hass) as session, - async_get_chat_log(hass, session) as chat_log, - ): - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat + agent_id = "conversation.google_generative_ai_conversation" + context = Context() - # Chat preparation with two inputs, one being an empty string - first_input = "First request" - second_input = "" - chat_log.async_add_user_content(UserContent(first_input)) - chat_log.async_add_user_content(UserContent(second_input)) + messages = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "Hi there!"}], + "role": "model", + }, + } + ], + ), + ], + ] + mock_send_message_stream.return_value = messages + + # Chat preparation with two inputs, one being an empty string + first_input = "First request" + second_input = "" + mock_chat_log.async_add_user_content(UserContent(first_input)) + mock_chat_log.async_add_user_content(UserContent(second_input)) + + with patch( + "google.genai.chats.AsyncChats.create", return_value=AsyncMock() + ) as mock_create: + mock_create.return_value.send_message_stream = mock_send_message_stream await conversation.async_converse( hass, - "Second request", - session.conversation_id, - Context(), - agent_id="conversation.google_generative_ai_conversation", + "Hello", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", ) - _, kwargs = mock_create.call_args - actual_history = kwargs.get("history") + _, kwargs = mock_create.call_args + actual_history = kwargs.get("history") - assert actual_history[0].parts[0].text == first_input - assert actual_history[1].parts[0].text == " " + assert actual_history[0].parts[0].text == first_input + assert actual_history[1].parts[0].text == " " @pytest.mark.usefixtures("mock_init_component") async def test_history_always_user_first_turn( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, ) -> None: """Test that the user is always first in the chat history.""" - with ( - chat_session.async_get_chat_session(hass) as session, - async_get_chat_log(hass, session) as chat_log, - ): - chat_log.async_add_assistant_content_without_tools( - conversation.AssistantContent( - agent_id="conversation.google_generative_ai_conversation", - content="Garage door left open, do you want to close it?", - ) + + agent_id = "conversation.google_generative_ai_conversation" + context = Context() + + messages = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "text": " Yes, I can help with that. ", + } + ], + "role": "model", + }, + } + ], + ), + ], + ] + + mock_send_message_stream.return_value = messages + + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id="conversation.google_generative_ai_conversation", + content="Garage door left open, do you want to close it?", ) + ) - with patch("google.genai.chats.AsyncChats.create") as mock_create: - mock_chat = AsyncMock() - mock_create.return_value.send_message = mock_chat - chat_response = Mock(prompt_feedback=None) - mock_chat.return_value = chat_response - chat_response.candidates = [Mock(content=Mock(parts=[]))] - + with patch( + "google.genai.chats.AsyncChats.create", return_value=AsyncMock() + ) as mock_create: + mock_create.return_value.send_message_stream = mock_send_message_stream await conversation.async_converse( hass, - "hello", - chat_log.conversation_id, - Context(), - agent_id="conversation.google_generative_ai_conversation", + "Hello", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", ) _, kwargs = mock_create.call_args diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 94308260f74..6cc0bdd5f44 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID +from . import API_ERROR_500, CLIENT_ERROR_API_KEY_INVALID from tests.common import MockConfigEntry @@ -212,7 +212,7 @@ async def test_generate_content_service_error( with ( patch( "google.genai.models.AsyncModels.generate_content", - side_effect=CLIENT_ERROR_500, + side_effect=API_ERROR_500, ), pytest.raises( HomeAssistantError, @@ -311,7 +311,7 @@ async def test_generate_content_service_with_image_not_exists( ("side_effect", "state", "reauth"), [ ( - CLIENT_ERROR_500, + API_ERROR_500, ConfigEntryState.SETUP_ERROR, False, ), diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 8cdb3c270d0..562ca152ce8 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -2,7 +2,12 @@ from unittest.mock import AsyncMock, patch -from google.api_core.exceptions import GatewayTimeout, GoogleAPIError, Unauthorized +from google.api_core.exceptions import ( + GatewayTimeout, + GoogleAPIError, + PermissionDenied, + Unauthorized, +) import pytest from homeassistant.components.google_travel_time.const import ( @@ -98,6 +103,12 @@ async def test_minimum_fields(hass: HomeAssistant) -> None: (GoogleAPIError("test"), "cannot_connect"), (GatewayTimeout("Timeout error."), "timeout_connect"), (Unauthorized("Invalid API key."), "invalid_auth"), + ( + PermissionDenied( + "Requests to this API routes.googleapis.com method google.maps.routing.v2.Routes.ComputeRoutes are blocked." + ), + "permission_denied", + ), ], ) async def test_errors( diff --git a/tests/components/google_travel_time/test_helpers.py b/tests/components/google_travel_time/test_helpers.py new file mode 100644 index 00000000000..058cb214ed7 --- /dev/null +++ b/tests/components/google_travel_time/test_helpers.py @@ -0,0 +1,46 @@ +"""Tests for google_travel_time.helpers.""" + +from google.maps.routing_v2 import Location, Waypoint +from google.type import latlng_pb2 +import pytest + +from homeassistant.components.google_travel_time import helpers +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + ("location", "expected_result"), + [ + ( + "12.34,56.78", + Waypoint( + location=Location( + lat_lng=latlng_pb2.LatLng( + latitude=12.34, + longitude=56.78, + ) + ) + ), + ), + ( + "12.34, 56.78", + Waypoint( + location=Location( + lat_lng=latlng_pb2.LatLng( + latitude=12.34, + longitude=56.78, + ) + ) + ), + ), + ("Some Address", Waypoint(address="Some Address")), + ("Some Street 1, 12345 City", Waypoint(address="Some Street 1, 12345 City")), + ], +) +def test_convert_to_waypoint_coordinates( + hass: HomeAssistant, location: str, expected_result: Waypoint +) -> None: + """Test convert_to_waypoint returns correct Waypoint for coordinates or address.""" + waypoint = helpers.convert_to_waypoint(hass, location) + + assert waypoint == expected_result diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index 58843d8275c..0ab5e38a644 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from google.api_core.exceptions import GoogleAPIError +from google.api_core.exceptions import GoogleAPIError, PermissionDenied from google.maps.routing_v2 import Units import pytest @@ -20,6 +20,7 @@ from homeassistant.components.google_travel_time.const import ( 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.helpers import issue_registry as ir from homeassistant.util.unit_system import ( METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, @@ -170,3 +171,26 @@ async def test_sensor_exception( 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 + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +async def test_sensor_routes_api_disabled( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, + freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that exception gets caught and issue created.""" + routes_mock.compute_routes.side_effect = PermissionDenied("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 "Routes API is disabled for this API key" in caplog.text + + assert len(issue_registry.issues) == 1 diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 9111b909f04..5a6ce0ce5a7 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -114,6 +114,7 @@ 'original_name': None, 'platform': 'gree', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'aabbcc112233', diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index c3fa3ae24c7..982afef30e8 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -92,6 +92,7 @@ 'original_name': 'Panel light', 'platform': 'gree', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'aabbcc112233_Panel Light', @@ -124,6 +125,7 @@ 'original_name': 'Quiet mode', 'platform': 'gree', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'quiet', 'unique_id': 'aabbcc112233_Quiet', @@ -156,6 +158,7 @@ 'original_name': 'Fresh air', 'platform': 'gree', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fresh_air', 'unique_id': 'aabbcc112233_Fresh Air', @@ -188,6 +191,7 @@ 'original_name': 'Xtra fan', 'platform': 'gree', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'xfan', 'unique_id': 'aabbcc112233_XFan', @@ -220,6 +224,7 @@ 'original_name': 'Health mode', 'platform': 'gree', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'health_mode', 'unique_id': 'aabbcc112233_Health mode', diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index acfa1ba43f5..1c67da1f675 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -6,11 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode -from homeassistant.components.gree.const import ( - COORDINATORS, - DOMAIN as GREE, - UPDATE_INTERVAL, -) +from homeassistant.components.gree.const import UPDATE_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -42,13 +38,13 @@ async def test_discovery_after_setup( discovery.return_value.mock_devices = [mock_device_1, mock_device_2] device.side_effect = [mock_device_1, mock_device_2] - await async_setup_gree(hass) + entry = await async_setup_gree(hass) await hass.async_block_till_done() assert discovery.return_value.scan_count == 1 assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 - device_infos = [x.device.device_info for x in hass.data[GREE][COORDINATORS]] + device_infos = [x.device.device_info for x in entry.runtime_data.coordinators] assert device_infos[0].ip == "1.1.1.1" assert device_infos[1].ip == "2.2.2.2" @@ -70,7 +66,7 @@ async def test_discovery_after_setup( assert discovery.return_value.scan_count == 2 assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 - device_infos = [x.device.device_info for x in hass.data[GREE][COORDINATORS]] + device_infos = [x.device.device_info for x in entry.runtime_data.coordinators] assert device_infos[0].ip == "1.1.1.2" assert device_infos[1].ip == "2.2.2.1" diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 187991141e7..de48c711587 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -481,7 +481,11 @@ async def test_sensor_with_uoms_but_no_device_class( assert state.attributes.get("unit_of_measurement") == "W" assert state.state == str(float(sum(VALUES))) - assert not issue_registry.issues + assert not [ + issue + for issue in issue_registry.issues.values() + if issue.domain == GROUP_DOMAIN + ] hass.states.async_set( entity_ids[0], diff --git a/tests/components/gstreamer/__init__.py b/tests/components/gstreamer/__init__.py new file mode 100644 index 00000000000..56369257098 --- /dev/null +++ b/tests/components/gstreamer/__init__.py @@ -0,0 +1 @@ +"""Gstreamer tests.""" diff --git a/tests/components/gstreamer/test_media_player.py b/tests/components/gstreamer/test_media_player.py new file mode 100644 index 00000000000..9fcf8eb7cfc --- /dev/null +++ b/tests/components/gstreamer/test_media_player.py @@ -0,0 +1,34 @@ +"""Tests for the Gstreamer platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.gstreamer import DOMAIN as GSTREAMER_DOMAIN +from homeassistant.components.media_player import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", gsp=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + PLATFORM_DOMAIN, + { + PLATFORM_DOMAIN: [ + { + CONF_PLATFORM: GSTREAMER_DOMAIN, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{GSTREAMER_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index 4487d0b6ac6..8851b6589f6 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Guardian diagnostics.""" from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.guardian import DOMAIN, GuardianData +from homeassistant.components.guardian import GuardianData from homeassistant.core import HomeAssistant from tests.common import ANY, MockConfigEntry @@ -16,7 +16,7 @@ async def test_entry_diagnostics( setup_guardian: None, # relies on config_entry fixture ) -> None: """Test config entry diagnostics.""" - data: GuardianData = hass.data[DOMAIN][config_entry.entry_id] + data: GuardianData = config_entry.runtime_data # Simulate the pairing of a paired sensor: await data.paired_sensor_manager.async_pair_sensor("AABBCCDDEEFF") diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 4ef14699e0b..fa2b65af6c3 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -155,18 +155,6 @@ async def mock_habiticalib() -> Generator[AsyncMock]: client.create_task.return_value = HabiticaTaskResponse.from_json( load_fixture("task.json", DOMAIN) ) - client.habitipy.return_value = { - "tasks": { - "user": { - "post": AsyncMock( - return_value={ - "text": "Use API from Home Assistant", - "type": "todo", - } - ) - } - } - } yield client diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json index e26dbeb17cc..e66186860c7 100644 --- a/tests/components/habitica/fixtures/content.json +++ b/tests/components/habitica/fixtures/content.json @@ -657,6 +657,31 @@ "canDrop": false, "key": "Saddle" } + }, + "loginIncentives": { + "0": { + "nextRewardAt": 1 + }, + "1": { + "rewardKey": ["armor_special_bardRobes"], + "reward": [ + { + "text": "Bardic Robes", + "notes": "These colorful robes may be conspicuous, but you can sing your way out of any situation. Increases Perception by 3.", + "per": 3, + "value": 0, + "type": "armor", + "key": "armor_special_bardRobes", + "set": "special-bardRobes", + "klass": "special", + "index": "bardRobes", + "str": 0, + "int": 0, + "con": 0 + } + ], + "nextRewardAt": 2 + } } }, "appVersion": "5.29.2" diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr index ffe4ce83d0e..247063f2ae8 100644 --- a/tests/components/habitica/snapshots/test_binary_sensor.ambr +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Pending quest invitation', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_pending_quest', diff --git a/tests/components/habitica/snapshots/test_button.ambr b/tests/components/habitica/snapshots/test_button.ambr index 5c6ad640039..9d7e2411590 100644 --- a/tests/components/habitica/snapshots/test_button.ambr +++ b/tests/components/habitica/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Allocate all stat points', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', @@ -74,6 +75,7 @@ 'original_name': 'Blessing', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_heal_all', @@ -122,6 +124,7 @@ 'original_name': 'Buy a health potion', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', @@ -170,6 +173,7 @@ 'original_name': 'Healing light', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_heal', @@ -218,6 +222,7 @@ 'original_name': 'Protective aura', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_protect_aura', @@ -266,6 +271,7 @@ 'original_name': 'Revive from death', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', @@ -313,6 +319,7 @@ 'original_name': 'Searing brightness', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_brightness', @@ -361,6 +368,7 @@ 'original_name': 'Start my day', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', @@ -408,6 +416,7 @@ 'original_name': 'Allocate all stat points', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', @@ -455,6 +464,7 @@ 'original_name': 'Buy a health potion', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', @@ -503,6 +513,7 @@ 'original_name': 'Revive from death', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', @@ -550,6 +561,7 @@ 'original_name': 'Start my day', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', @@ -597,6 +609,7 @@ 'original_name': 'Stealth', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_stealth', @@ -645,6 +658,7 @@ 'original_name': 'Tools of the trade', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_tools_of_trade', @@ -693,6 +707,7 @@ 'original_name': 'Allocate all stat points', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', @@ -740,6 +755,7 @@ 'original_name': 'Buy a health potion', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', @@ -788,6 +804,7 @@ 'original_name': 'Defensive stance', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_defensive_stance', @@ -836,6 +853,7 @@ 'original_name': 'Intimidating gaze', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_intimidate', @@ -884,6 +902,7 @@ 'original_name': 'Revive from death', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', @@ -931,6 +950,7 @@ 'original_name': 'Start my day', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', @@ -978,6 +998,7 @@ 'original_name': 'Valorous presence', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_valorous_presence', @@ -1026,6 +1047,7 @@ 'original_name': 'Allocate all stat points', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', @@ -1073,6 +1095,7 @@ 'original_name': 'Buy a health potion', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', @@ -1121,6 +1144,7 @@ 'original_name': 'Chilling frost', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_frost', @@ -1169,6 +1193,7 @@ 'original_name': 'Earthquake', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_earth', @@ -1217,6 +1242,7 @@ 'original_name': 'Ethereal surge', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mpheal', @@ -1265,6 +1291,7 @@ 'original_name': 'Revive from death', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', @@ -1312,6 +1339,7 @@ 'original_name': 'Start my day', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index c7f12684efe..a59b984c63e 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -955,6 +955,7 @@ 'original_name': 'Dailies', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys', @@ -1009,6 +1010,7 @@ 'original_name': 'Daily reminders', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_daily_reminders', @@ -1062,6 +1064,7 @@ 'original_name': 'To-do reminders', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todo_reminders', @@ -1115,6 +1118,7 @@ 'original_name': "To-Do's", 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos', diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index 718aea99ebc..e04edea3d94 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -541,6 +541,8 @@ 'quest': dict({ 'RSVPNeeded': True, 'key': 'dustbunnies', + 'members': dict({ + }), 'progress': dict({ 'collect': dict({ }), diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 1fbc9eca595..06f9ff9a6cd 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -34,6 +34,7 @@ 'original_name': 'Class', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_class', @@ -92,6 +93,7 @@ 'original_name': 'Constitution', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_constitution', @@ -145,6 +147,7 @@ 'original_name': 'Display name', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_display_name', @@ -197,6 +200,7 @@ 'original_name': 'Eggs', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_eggs_total', @@ -249,6 +253,7 @@ 'original_name': 'Experience', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_experience', @@ -301,6 +306,7 @@ 'original_name': 'Gems', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_gems', @@ -353,6 +359,7 @@ 'original_name': 'Gold', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_gold', @@ -402,6 +409,7 @@ 'original_name': 'Habits', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': 'test_user_habits', 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_habits', @@ -609,6 +617,7 @@ 'original_name': 'Hatching potions', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_hatching_potions_total', @@ -664,6 +673,7 @@ 'original_name': 'Health', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health', @@ -716,6 +726,7 @@ 'original_name': 'Intelligence', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_intelligence', @@ -769,6 +780,7 @@ 'original_name': 'Level', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_level', @@ -819,6 +831,7 @@ 'original_name': 'Mana', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mana', @@ -868,6 +881,7 @@ 'original_name': 'Max. health', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': 'test_user_max_health', 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health_max', @@ -916,6 +930,7 @@ 'original_name': 'Max. mana', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mana_max', @@ -968,6 +983,7 @@ 'original_name': 'Mystic hourglasses', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_trinkets', @@ -1017,6 +1033,7 @@ 'original_name': 'Next level', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_experience_max', @@ -1038,6 +1055,108 @@ 'state': '880', }) # --- +# name: test_sensors[sensor.test_user_pending_damage-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.test_user_pending_damage', + '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': None, + 'original_icon': None, + 'original_name': 'Pending damage', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_pending_damage', + 'unit_of_measurement': 'damage', + }) +# --- +# name: test_sensors[sensor.test_user_pending_damage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSItMyAtMyAyMiAyMiI+CiAgICA8ZGVmcz4KICAgICAgICA8cGF0aCBpZD0iYSIgZD0iTTEwLjQ2NCAyLjkxN0w4LjIgNS4xOTd2Mi4wMmwyLjI2NC0yLjE3M1YyLjkxN3oiPjwvcGF0aD4KICAgIDwvZGVmcz4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTYgLjMyKSI+CiAgICAgICAgPHBhdGggZmlsbD0iI0YwNjE2NiIgZD0iTTYuMTMgOS4yMDRsMi4xMTEuOTM0Yy4xNzYuMDc4LjI5LjIzNS4zMzMuNDE1LjA3My4zMDQuMjk1IDEuMDEuMzEzIDEuMzg2LjAxLjIxLS4yMTQuMzU2LS40MTQuMjdsLTMuNTI5LTEuNjIzYS41ODIuNTgyIDAgMCAxLS4yNTQtLjI0NEwzIDYuOTU1Yy0uMDktLjE5Mi4wNjMtLjQwNy4yODEtLjM5Ny4zOTEuMDE3IDEuMTEyLjIxOCAxLjQ0NC4zLjE4Ni4wNDUuMzUxLjE1LjQzMi4zMTlsLjk3MyAyLjAyN3oiPjwvcGF0aD4KICAgICAgICA8cGF0aCBmaWxsPSIjODc4MTkwIiBkPSJNMS4wMjQgMTQuMTA3bC45MS44NzUgMi4zNjMtLjE3OS4xMjEtMS40OSAxLjM1Ni0xLjMgMi40NjcgMS4xMjYgMS44NDYtLjQ3Ny0uNzc0LTMuMTk2IDUuMTcxLTQuNjMzLjk5LTQuNTk2aC0uMDAyVi4yMzVsLTQuNzg2Ljk1TDUuODYgNi4xNWwtMy4zMy0uNzQzLS40OTcgMS43NyAxLjE3NCAyLjM3LTEuMzU1IDEuMy0xLjU1Mi4xMTgtLjE4NiAyLjI2Ny45MS44NzV6Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZmlsbD0iI0YwNjE2NiIgZD0iTTIuOTc2IDEzLjM2NmwtMS4xODItMS4xMzQgMi45MjMtMi44MDUgMS4xOCAxLjEzNHoiPjwvcGF0aD4KICAgICAgICA8cGF0aCBmaWxsPSIjRjA2MTY2IiBkPSJNMS4xMjYgMTIuODc0bC4wODUtMS4wMzUgMS4wNzgtLjA4MiAxLjE4MiAxLjEzNS0uMDg1IDEuMDM1LTEuMDc4LjA4MnoiPjwvcGF0aD4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNMTEuMzEyIDIuMDg4bC4xIDIuMDQ2IDIuNzAyLTIuNTk1Yy0uMDUtLjA0NS0yLjA4Ni4xOC0yLjgwMi41NSI+PC9wYXRoPgogICAgICAgIDxwYXRoIGZpbGw9IiNFREVDRUUiIGQ9Ik0xMS4yNjIgMi4xMTNMNS41NTMgNy44NjJsMS40NjMuNDkyIDQuMzk2LTQuMjItLjEtMi4wNDYtLjA1LjAyNU01LjU1MyA3Ljg2MmwtLjA1LjA1Mi42MjIgMS4yOTQuODktLjg1NC0xLjQ2Mi0uNDkyeiI+PC9wYXRoPgogICAgICAgIDxwYXRoIGZpbGw9IiNFREVDRUUiIGQ9Ik0xMy41NDEgNC4yM2wtMi4xMy0uMDk2IDIuNzAzLTIuNTk0Yy4wNDYuMDQ4LS4xODkgMi4wMDMtLjU3MyAyLjY5Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZmlsbD0iI0UxRTBFMyIgZD0iTTEzLjUxNiA0LjI3OGwtNS45ODkgNS40OC0uNTEyLTEuNDA0IDQuMzk2LTQuMjIgMi4xMy4wOTYtLjAyNS4wNDhNNy41MjcgOS43NThsLS4wNTQuMDQ4LTEuMzQ4LS41OTcuODktLjg1NC41MTIgMS40MDN6Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZmlsbD0iI0Q1QzhGRiIgZD0iTTIuMjg5IDExLjc1N2wtLjI1Ljg3OC0uODI5LS43OTZ6TTMuNDY5IDEyLjg5bC0uOTE0LjI0LjgyOC43OTV6Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZmlsbD0iI0JEQThGRiIgZD0iTTIuMjg5IDExLjc1N2wxLjE4MiAxLjEzNS0uOTE2LjIzNy0uNTE2LS40OTR6Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZmlsbD0iI0JEQThGRiIgZD0iTTEuMTI3IDEyLjg3NmwuOTE0LS4yNC0uODI4LS43OTZ6TTIuMzA3IDE0LjAwOGwuMjUtLjg3Ny44MjkuNzk2eiI+PC9wYXRoPgogICAgICAgIDxwYXRoIGZpbGw9IiNENUM4RkYiIGQ9Ik0xLjEyNyAxMi44NzZMMi4zMSAxNC4wMWwuMjQ3LS44OC0uNTE2LS40OTV6Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZmlsbD0iI0IzNjIxMyIgZD0iTTQuOSAxMS41MjNsLTEuMTg0LTEuMTM3LjcxNS0uNjg1IDEuMTg0IDEuMTM2eiI+PC9wYXRoPgogICAgICAgIDxwYXRoIGZpbGw9IiNFMzhGM0QiIGQ9Ik00LjE4NyAxMi4yMDhsLTEuMTg0LTEuMTM2LjcxNC0uNjg1TDQuOSAxMS41MjN6Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZmlsbD0iI0IzNjIxMyIgZD0iTTMuNDczIDEyLjg5NGwtMS4xODQtMS4xMzcuNzE0LS42ODUgMS4xODQgMS4xMzZ6Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZmlsbD0iI0MzQzBDNyIgZD0iTTYuMTMyIDkuMjA1bC0uOTc0LTIuMDI3YS41MjYuNTI2IDAgMCAwLS4xNTMtLjE4NS43MTguNzE4IDAgMCAwLS4yNzktLjEzNWMtLjMzMS0uMDgtMS4wNTItLjI4Mi0xLjQ0My0uM2EuMjk1LjI5NSAwIDAgMC0uMjQyLjEwOEw0LjQ2IDcuODI5IDUuNTAzIDkuODFsLjYzLS42MDVoLS4wMDF6Ij48L3BhdGg+CiAgICAgICAgPHBhdGggZmlsbD0iI0E1QTFBQyIgZD0iTTQuNDYgNy44MjlMMy4wNCA2LjY2NmEuMjcuMjcgMCAwIDAtLjAzOS4yOWwxLjY5IDMuMzg3Yy4wMjkuMDUzLjA2Ni4xLjExLjE0MmwuNzAyLS42NzVMNC40NiA3LjgzeiI+PC9wYXRoPgogICAgICAgIDxwYXRoIGZpbGw9IiNDM0MwQzciIGQ9Ik04Ljg5MSAxMS45NGMtLjAxOC0uMzc1LS4yMjgtMS4wNjgtLjMxMi0xLjM4NWEuNjY4LjY2OCAwIDAgMC0uMTQtLjI2OC41NC41NCAwIDAgMC0uMTkzLS4xNDdsLTIuMTExLS45MzV2LS4wMDJsLS42MzEuNjA2IDIuMDY0IDEuMDAxIDEuMjExIDEuMzYzYS4yNzUuMjc1IDAgMCAwIC4xMTItLjIzMyI+PC9wYXRoPgogICAgICAgIDxwYXRoIGZpbGw9IiNBNUExQUMiIGQ9Ik03LjU2OCAxMC44MWwxLjIxMSAxLjM2M2EuMy4zIDAgMCAxLS4zMDEuMDM3bC0zLjUzLTEuNjIyYS41ODguNTg4IDAgMCAxLS4xNDctLjEwNWwuNzAzLS42NzQgMi4wNjQgMS4wMDF6Ij48L3BhdGg+CiAgICAgICAgPG1hc2sgaWQ9ImIiIGZpbGw9IiNmZmYiPgogICAgICAgICAgICA8dXNlIHhsaW5rOmhyZWY9IiNhIj48L3VzZT4KICAgICAgICA8L21hc2s+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTkuODI0IDcuNDVoLjMyOVYxLjg2NWgtLjMyOXpNOC4yIDguODYyaC45NzRWMy4yNzdIOC4yeiIgbWFzaz0idXJsKCNiKSI+PC9wYXRoPgogICAgPC9nPgo8L3N2Zz4=', + 'friendly_name': 'test-user Pending damage', + 'unit_of_measurement': 'damage', + }), + 'context': , + 'entity_id': 'sensor.test_user_pending_damage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_user_pending_quest_items-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.test_user_pending_quest_items', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pending quest items', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_pending_quest_items', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_sensors[sensor.test_user_pending_quest_items-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test-user Pending quest items', + 'unit_of_measurement': 'items', + }), + 'context': , + 'entity_id': 'sensor.test_user_pending_quest_items', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.test_user_perception-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1069,6 +1188,7 @@ 'original_name': 'Perception', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_perception', @@ -1122,6 +1242,7 @@ 'original_name': 'Pet food', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_food_total', @@ -1174,6 +1295,7 @@ 'original_name': 'Quest scrolls', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_quest_scrolls', @@ -1227,6 +1349,7 @@ 'original_name': 'Rewards', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': 'test_user_rewards', 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_rewards', @@ -1318,6 +1441,7 @@ 'original_name': 'Saddles', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_saddle', @@ -1370,6 +1494,7 @@ 'original_name': 'Strength', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_strength', diff --git a/tests/components/habitica/snapshots/test_switch.ambr b/tests/components/habitica/snapshots/test_switch.ambr index e8122f77c6e..7794f8f5e8d 100644 --- a/tests/components/habitica/snapshots/test_switch.ambr +++ b/tests/components/habitica/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Rest in the inn', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_sleep', diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index fef9404a0f0..52f901322a3 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -141,6 +141,7 @@ 'original_name': 'Dailies', 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys', @@ -189,6 +190,7 @@ 'original_name': "To-Do's", 'platform': 'habitica', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': , 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos', diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index e953ec254d6..e904ccc890d 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -8,17 +8,9 @@ from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.habitica.const import ( - ATTR_ARGS, - ATTR_DATA, - ATTR_PATH, - DOMAIN, - EVENT_API_CALL_SUCCESS, - SERVICE_API_CALL, -) +from homeassistant.components.habitica.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_NAME -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import HomeAssistant from .conftest import ( ERROR_BAD_REQUEST, @@ -27,13 +19,7 @@ from .conftest import ( ERROR_TOO_MANY_REQUESTS, ) -from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed - - -@pytest.fixture -def capture_api_call_success(hass: HomeAssistant) -> list[Event]: - """Capture api_call events.""" - return async_capture_events(hass, EVENT_API_CALL_SUCCESS) +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("habitica") @@ -53,37 +39,6 @@ async def test_entry_setup_unload( assert config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.usefixtures("habitica") -async def test_service_call( - hass: HomeAssistant, - config_entry: MockConfigEntry, - capture_api_call_success: list[Event], -) -> None: - """Test integration setup, service call 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 len(capture_api_call_success) == 0 - - TEST_SERVICE_DATA = { - ATTR_NAME: "test-user", - ATTR_PATH: ["tasks", "user", "post"], - ATTR_ARGS: {"text": "Use API from Home Assistant", "type": "todo"}, - } - await hass.services.async_call( - DOMAIN, SERVICE_API_CALL, TEST_SERVICE_DATA, blocking=True - ) - - assert len(capture_api_call_success) == 1 - captured_data = capture_api_call_success[0].data - captured_data[ATTR_ARGS] = captured_data[ATTR_DATA] - del captured_data[ATTR_DATA] - assert captured_data == TEST_SERVICE_DATA - - @pytest.mark.parametrize( ("exception"), [ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS, ClientError], diff --git a/tests/components/hardware/test_websocket_api.py b/tests/components/hardware/test_websocket_api.py index 64fcda02df4..f5591ff8480 100644 --- a/tests/components/hardware/test_websocket_api.py +++ b/tests/components/hardware/test_websocket_api.py @@ -50,7 +50,7 @@ async def test_system_status_subscription( return mock_psutil with patch( - "homeassistant.components.hardware.websocket_api.ha_psutil.PsutilWrapper", + "homeassistant.components.hardware.ha_psutil.PsutilWrapper", wraps=create_mock_psutil, ): assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index ea38865ac5a..56f7ffaa5b9 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -63,7 +63,7 @@ async def hassio_client_supervisor( @pytest.fixture -def hassio_handler( +async def hassio_handler( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> Generator[HassIO]: """Create mock hassio handler.""" @@ -260,3 +260,16 @@ def all_setup_requests( }, }, ) + + +@pytest.fixture +def arch() -> str: + """Arch found in apk file.""" + return "amd64" + + +@pytest.fixture(autouse=True) +def mock_arch_file(arch: str) -> Generator[None]: + """Mock arch file.""" + with patch("homeassistant.components.hassio._get_arch", return_value=arch): + yield diff --git a/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json b/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json new file mode 100644 index 00000000000..183a38a60db --- /dev/null +++ b/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json @@ -0,0 +1,162 @@ +{ + "result": "ok", + "data": { + "name": "backup_manager_partial_backup", + "reference": "14a1ea4b", + "uuid": "400a90112553472a90d84a7e60d5265e", + "progress": 0, + "stage": "finishing_file", + "done": true, + "errors": [], + "created": "2025-05-14T08:56:22.801143+00:00", + "child_jobs": [ + { + "name": "backup_store_homeassistant", + "reference": "14a1ea4b", + "uuid": "176318a1a8184b02b7e9ad3ec54ee5ec", + "progress": 0, + "stage": null, + "done": true, + "errors": [], + "created": "2025-05-14T08:56:22.807078+00:00", + "child_jobs": [] + }, + { + "name": "backup_store_addons", + "reference": "14a1ea4b", + "uuid": "42664cb8fd4e474f8919bd737877125b", + "progress": 0, + "stage": null, + "done": true, + "errors": [ + { + "type": "BackupError", + "message": "Can't backup add-on core_ssh: Can't write tarfile: FAKE OS error during add-on backup", + "stage": null + }, + { + "type": "BackupError", + "message": "Can't backup add-on core_whisper: Can't write tarfile: FAKE OS error during add-on backup", + "stage": null + } + ], + "created": "2025-05-14T08:56:22.843960+00:00", + "child_jobs": [ + { + "name": "backup_addon_save", + "reference": "core_ssh", + "uuid": "7cc7feb782e54345bdb5ca653928233f", + "progress": 0, + "stage": null, + "done": true, + "errors": [ + { + "type": "BackupError", + "message": "Can't write tarfile: FAKE OS error during add-on backup", + "stage": null + } + ], + "created": "2025-05-14T08:56:22.844160+00:00", + "child_jobs": [] + }, + { + "name": "backup_addon_save", + "reference": "core_whisper", + "uuid": "0cfb1163751740929e63a68df59dc13b", + "progress": 0, + "stage": null, + "done": true, + "errors": [ + { + "type": "BackupError", + "message": "Can't write tarfile: FAKE OS error during add-on backup", + "stage": null + } + ], + "created": "2025-05-14T08:56:22.850376+00:00", + "child_jobs": [] + } + ] + }, + { + "name": "backup_store_folders", + "reference": "14a1ea4b", + "uuid": "dd4685b4aac9460ab0e1150fe5c968e1", + "progress": 0, + "stage": null, + "done": true, + "errors": [ + { + "type": "BackupError", + "message": "Can't backup folder share: Can't write tarfile: FAKE OS error during folder backup", + "stage": null + }, + { + "type": "BackupError", + "message": "Can't backup folder ssl: Can't write tarfile: FAKE OS error during folder backup", + "stage": null + }, + { + "type": "BackupError", + "message": "Can't backup folder media: Can't write tarfile: FAKE OS error during folder backup", + "stage": null + } + ], + "created": "2025-05-14T08:56:22.858227+00:00", + "child_jobs": [ + { + "name": "backup_folder_save", + "reference": "share", + "uuid": "8a4dccd988f641a383abb469a478cbdb", + "progress": 0, + "stage": null, + "done": true, + "errors": [ + { + "type": "BackupError", + "message": "Can't write tarfile: FAKE OS error during folder backup", + "stage": null + } + ], + "created": "2025-05-14T08:56:22.858385+00:00", + "child_jobs": [] + }, + { + "name": "backup_folder_save", + "reference": "ssl", + "uuid": "f9b437376cc9428090606779eff35b41", + "progress": 0, + "stage": null, + "done": true, + "errors": [ + { + "type": "BackupError", + "message": "Can't write tarfile: FAKE OS error during folder backup", + "stage": null + } + ], + "created": "2025-05-14T08:56:22.859973+00:00", + "child_jobs": [] + }, + { + "name": "backup_folder_save", + "reference": "media", + "uuid": "b920835ef079403784fba4ff54437197", + "progress": 0, + "stage": null, + "done": true, + "errors": [ + { + "type": "BackupError", + "message": "Can't write tarfile: FAKE OS error during folder backup", + "stage": null + } + ], + "created": "2025-05-14T08:56:22.860792+00:00", + "child_jobs": [] + } + ] + } + ] + } +} diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index af951fe8aa1..4bf420e6b0d 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -10,6 +10,7 @@ from collections.abc import ( Iterable, ) from dataclasses import replace +import datetime as dt from datetime import datetime from io import StringIO import os @@ -32,7 +33,7 @@ from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL from aiohasupervisor.models.mounts import MountsInfo from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -47,12 +48,13 @@ from homeassistant.components.backup import ( from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON -from tests.common import mock_platform +from tests.common import load_json_object_fixture, mock_platform from tests.typing import ClientSessionGenerator, WebSocketGenerator TEST_BACKUP = supervisor_backups.Backup( @@ -495,7 +497,9 @@ async def test_agent_info( "database_included": True, "date": "1970-01-01T00:00:00+00:00", "extra_metadata": {}, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": ["share"], "homeassistant_included": True, "homeassistant_version": "2024.12.0", @@ -515,7 +519,9 @@ async def test_agent_info( "database_included": False, "date": "1970-01-01T00:00:00+00:00", "extra_metadata": {}, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": ["share"], "homeassistant_included": False, "homeassistant_version": None, @@ -651,7 +657,9 @@ async def test_agent_get_backup( "database_included": True, "date": "1970-01-01T00:00:00+00:00", "extra_metadata": {}, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": ["share"], "homeassistant_included": True, "homeassistant_version": "2024.12.0", @@ -986,6 +994,128 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("addon_info", "hassio_client", "setup_backup_integration") +@pytest.mark.parametrize( + "addon_info_side_effect", + # Getting info fails for one of the addons, should fall back to slug + [[Mock(slug="core_ssh", version="0.0.0"), SupervisorError("Boom")]], +) +async def test_reader_writer_create_addon_folder_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + supervisor_client: AsyncMock, + addon_info_side_effect: list[Any], +) -> None: + """Test generating a backup.""" + addon_info_side_effect[0].name = "Advanced SSH & Web Terminal" + assert dt.datetime.__name__ == "HAFakeDatetime" + assert dt.HAFakeDatetime.__name__ == "HAFakeDatetime" + client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.side_effect = [ + TEST_JOB_NOT_DONE, + supervisor_jobs.Job.from_dict( + load_json_object_fixture( + "backup_done_with_addon_folder_errors.json", DOMAIN + )["data"] + ), + ] + + issue_registry = ir.async_get(hass) + assert not issue_registry.issues + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["hassio.local"], + "include_addons": ["core_ssh", "core_whisper"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media", "share"], + "name": "Test", + }, + } + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id({"type": "backup/generate_with_automatic_settings"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + replace( + DEFAULT_BACKUP_OPTIONS, + addons={"core_ssh", "core_whisper"}, + extra=DEFAULT_BACKUP_OPTIONS.extra | {"with_automatic_settings": True}, + folders={Folder.MEDIA, Folder.SHARE, Folder.SSL}, + ) + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + # Check that the expected issue was created + assert list(issue_registry.issues) == [("backup", "automatic_backup_failed")] + issue = issue_registry.issues[("backup", "automatic_backup_failed")] + assert issue.translation_key == "automatic_backup_failed_agents_addons_folders" + assert issue.translation_placeholders == { + "failed_addons": "Advanced SSH & Web Terminal, core_whisper", + "failed_agents": "-", + "failed_folders": "share, ssl, media", + } + + @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_report_progress( hass: HomeAssistant, @@ -1176,6 +1306,16 @@ async def test_reader_writer_create_job_done( False, [], ), + # LOCATION_LOCAL_STORAGE should be moved to the front of the list + ( + [], + None, + ["hassio.share1", "hassio.local", "hassio.share2", "hassio.share3"], + None, + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], + False, + [], + ), ( [], "hunter2", @@ -1185,54 +1325,86 @@ async def test_reader_writer_create_job_done( True, [], ), + # LOCATION_LOCAL_STORAGE should be moved to the front of the list ( - [ - { - "type": "backup/config/update", - "agents": { - "hassio.local": {"protected": False}, - }, - } - ], + [], "hunter2", - ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + ["hassio.share1", "hassio.local", "hassio.share2", "hassio.share3"], "hunter2", - ["share1", "share2", "share3"], + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], True, - [LOCATION_LOCAL_STORAGE], + [], ), + # Prefer the list of locations which has LOCATION_LOCAL_STORAGE ( [ { "type": "backup/config/update", "agents": { "hassio.local": {"protected": False}, - "hassio.share1": {"protected": False}, - }, - } - ], - "hunter2", - ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], - "hunter2", - ["share2", "share3"], - True, - [LOCATION_LOCAL_STORAGE, "share1"], - ), - ( - [ - { - "type": "backup/config/update", - "agents": { - "hassio.local": {"protected": False}, - "hassio.share1": {"protected": False}, - "hassio.share2": {"protected": False}, }, } ], "hunter2", ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], None, - [LOCATION_LOCAL_STORAGE, "share1", "share2"], + [LOCATION_LOCAL_STORAGE], + True, + ["share1", "share2", "share3"], + ), + # If the list of locations does not have LOCATION_LOCAL_STORAGE, send the + # longest list + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.share0": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.share0", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share1", "share2", "share3"], + True, + ["share0"], + ), + # Prefer the list of encrypted locations if the lists are the same length + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.share0": {"protected": False}, + "hassio.share1": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.share0", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share2", "share3"], + True, + ["share0", "share1"], + ), + # If the list of locations does not have LOCATION_LOCAL_STORAGE, send the + # longest list + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.share0": {"protected": False}, + "hassio.share1": {"protected": False}, + "hassio.share2": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.share0", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + ["share0", "share1", "share2"], True, ["share3"], ), @@ -1283,7 +1455,7 @@ async def test_reader_writer_create_per_agent_encryption( server=f"share{i}", type=supervisor_mounts.MountType.CIFS, ) - for i in range(1, 4) + for i in range(4) ], ) supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) diff --git a/tests/components/hassio/test_config.py b/tests/components/hassio/test_config.py index 86a97cc4a0a..4df8d2e81ac 100644 --- a/tests/components/hassio/test_config.py +++ b/tests/components/hassio/test_config.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from uuid import UUID import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.hassio.const import DATA_CONFIG_STORE, DOMAIN diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index d34aed608fb..f424beedc85 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import AddonsStats +from freezegun.api import FrozenDateTimeFactory import pytest from voluptuous import Invalid @@ -23,10 +24,13 @@ from homeassistant.components.hassio import ( 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.hassio.const import ( + HASSIO_UPDATE_INTERVAL, + 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 +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.setup import async_setup_component @@ -1140,3 +1144,346 @@ def test_deprecated_constants( replacement, "2025.11", ) + + +@pytest.mark.parametrize( + ("board", "issue_id"), + [ + ("rpi3", "deprecated_os_aarch64"), + ("rpi4", "deprecated_os_aarch64"), + ("tinker", "deprecated_os_armv7"), + ("odroid-xu4", "deprecated_os_armv7"), + ("rpi2", "deprecated_os_armv7"), + ], +) +@pytest.mark.parametrize( + "arch", + ["armv7"], +) +async def test_deprecated_installation_issue_os_armv7( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + board: str, + issue_id: str, +) -> None: + """Test deprecated installation issue.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant OS", + "arch": "armv7", + }, + ), + patch( + "homeassistant.components.hassio._is_32_bit", + return_value=True, + ), + patch( + "homeassistant.components.hassio.get_os_info", return_value={"board": board} + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": True} + ), + patch("homeassistant.components.hardware.async_setup", return_value=True), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue("homeassistant", issue_id) + assert issue.domain == "homeassistant" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == { + "installation_guide": "https://www.home-assistant.io/installation/", + } + + +@pytest.mark.parametrize( + "arch", + [ + "i386", + "armhf", + "armv7", + ], +) +async def test_deprecated_installation_issue_32bit_os( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + arch: str, +) -> None: + """Test deprecated architecture issue.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant OS", + "arch": arch, + }, + ), + patch( + "homeassistant.components.hassio._is_32_bit", + return_value=True, + ), + patch( + "homeassistant.components.hassio.get_os_info", + return_value={"board": "rpi3-64"}, + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": True} + ), + patch("homeassistant.components.hardware.async_setup", return_value=True), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue("homeassistant", "deprecated_architecture") + assert issue.domain == "homeassistant" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == {"installation_type": "OS", "arch": arch} + + +@pytest.mark.parametrize( + "arch", + [ + "i386", + "armhf", + "armv7", + ], +) +async def test_deprecated_installation_issue_32bit_supervised( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + arch: str, +) -> None: + """Test deprecated architecture issue.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Supervised", + "arch": arch, + }, + ), + patch( + "homeassistant.components.hassio._is_32_bit", + return_value=True, + ), + patch( + "homeassistant.components.hassio.get_os_info", + return_value={"board": "rpi3-64"}, + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": None} + ), + patch("homeassistant.components.hardware.async_setup", return_value=True), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + "homeassistant", "deprecated_method_architecture" + ) + assert issue.domain == "homeassistant" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == { + "installation_type": "Supervised", + "arch": arch, + } + + +@pytest.mark.parametrize( + "arch", + [ + "amd64", + "aarch64", + ], +) +async def test_deprecated_installation_issue_64bit_supervised( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + arch: str, +) -> None: + """Test deprecated architecture issue.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Supervised", + "arch": arch, + }, + ), + patch( + "homeassistant.components.hassio._is_32_bit", + return_value=False, + ), + patch( + "homeassistant.components.hassio.get_os_info", + return_value={"board": "generic-x86-64"}, + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": None} + ), + patch("homeassistant.components.hardware.async_setup", return_value=True), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue("homeassistant", "deprecated_method") + assert issue.domain == "homeassistant" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == { + "installation_type": "Supervised", + "arch": arch, + } + + +@pytest.mark.parametrize( + ("board", "issue_id"), + [ + ("rpi5", "deprecated_os_aarch64"), + ], +) +async def test_deprecated_installation_issue_supported_board( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + board: str, + issue_id: str, +) -> None: + """Test no deprecated installation issue for a supported board.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant OS", + "arch": "aarch64", + }, + ), + patch( + "homeassistant.components.hassio._is_32_bit", + return_value=False, + ), + patch( + "homeassistant.components.hassio.get_os_info", return_value={"board": board} + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": True} + ), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 0 diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index cbf664d0e49..8c68e9bf705 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -7,7 +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 syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import BackupManagerError, ManagerBackup diff --git a/tests/components/hddtemp/test_sensor.py b/tests/components/hddtemp/test_sensor.py index 15740ffa0ea..62882c7df8b 100644 --- a/tests/components/hddtemp/test_sensor.py +++ b/tests/components/hddtemp/test_sensor.py @@ -1,12 +1,15 @@ """The tests for the hddtemp platform.""" import socket -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest +from homeassistant.components.hddtemp import DOMAIN +from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component VALID_CONFIG_MINIMAL = {"sensor": {"platform": "hddtemp"}} @@ -132,7 +135,7 @@ async def test_hddtemp_one_disk(hass: HomeAssistant, telnetmock) -> None: reference = REFERENCE[state.attributes.get("device")] - assert state.state == reference["temperature"] + assert round(float(state.state), 0) == float(reference["temperature"]) assert state.attributes.get("device") == reference["device"] assert state.attributes.get("model") == reference["model"] assert ( @@ -192,3 +195,17 @@ async def test_hddtemp_host_unreachable(hass: HomeAssistant, telnetmock) -> None assert await async_setup_component(hass, "sensor", VALID_CONFIG_HOST_UNREACHABLE) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@patch.dict("sys.modules", gsp=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component(hass, PLATFORM_DOMAIN, VALID_CONFIG_MINIMAL) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/here_travel_time/test_init.py b/tests/components/here_travel_time/test_init.py index 682d8c560bb..ff09c7e6ae9 100644 --- a/tests/components/here_travel_time/test_init.py +++ b/tests/components/here_travel_time/test_init.py @@ -50,4 +50,3 @@ async def test_unload_entry(hass: HomeAssistant, options) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) - assert not hass.data[DOMAIN] diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index 4cd999ba31c..f418b1f7ef1 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -2,24 +2,107 @@ from __future__ import annotations +from unittest.mock import patch + +import pytest + +from homeassistant.components import history_stats +from homeassistant.components.history_stats.config_flow import ( + HistoryStatsConfigFlowHandler, +) from homeassistant.components.history_stats.const import ( CONF_END, CONF_START, DEFAULT_NAME, DOMAIN as HISTORY_STATS_DOMAIN, ) -from homeassistant.components.recorder import Recorder -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry -async def test_unload_entry( - recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry -) -> None: +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def history_stats_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a history_stats config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=HISTORY_STATS_DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: sensor_entity_entry.entity_id, + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=HistoryStatsConfigFlowHandler.VERSION, + minor_version=HistoryStatsConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + +@pytest.mark.usefixtures("recorder_mock") +async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: """Test unload an entry.""" assert loaded_entry.state is ConfigEntryState.LOADED @@ -28,8 +111,8 @@ async def test_unload_entry( assert loaded_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("recorder_mock") async def test_device_cleaning( - recorder_mock: Recorder, hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -116,3 +199,200 @@ async def test_device_cleaning( assert len(devices_after_reload) == 1 assert devices_after_reload[0].id == source_device1_entry.id + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the history_stats config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the history_stats config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + + # Check that the history_stats config entry is removed + assert ( + history_stats_config_entry.entry_id not in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == ["remove"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the history_stats config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + + # Check that the history_stats config entry is not removed + assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the history_stats config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert history_stats_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the history_stats config entry is not removed + assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the history_stats config entry is updated with the new entity ID + assert history_stats_config_entry.options[CONF_ENTITY_ID] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + # Check that the history_stats config entry is not removed + assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py index 6733d38442b..463f8645647 100644 --- a/tests/components/holiday/test_calendar.py +++ b/tests/components/holiday/test_calendar.py @@ -3,13 +3,18 @@ from datetime import datetime, timedelta from freezegun.api import FrozenDateTimeFactory +from holidays import CATHOLIC import pytest from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, SERVICE_GET_EVENTS, ) -from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.components.holiday.const import ( + CONF_CATEGORIES, + CONF_PROVINCE, + DOMAIN, +) from homeassistant.const import CONF_COUNTRY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -353,3 +358,76 @@ async def test_language_not_exist( ] } } + + +async def test_categories( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if there is no next event.""" + await hass.config.async_set_time_zone("Europe/Berlin") + zone = await dt_util.async_get_time_zone("Europe/Berlin") + freezer.move_to(datetime(2025, 8, 14, 12, tzinfo=zone)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BY", + }, + options={ + CONF_CATEGORIES: [CATHOLIC], + }, + title="Germany", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.germany", + "end_date_time": dt_util.now() + timedelta(days=2), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.germany": { + "events": [ + { + "start": "2025-08-15", + "end": "2025-08-16", + "summary": "Assumption Day", + "location": "Germany", + } + ] + } + } + + freezer.move_to(datetime(2025, 12, 23, 12, tzinfo=zone)) + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.germany", + "end_date_time": dt_util.now() + timedelta(days=2), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.germany": { + "events": [ + { + "start": "2025-12-25", + "end": "2025-12-26", + "summary": "Christmas Day", + "location": "Germany", + } + ] + } + } diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 516701f2360..4442f9622de 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -36,6 +36,7 @@ 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.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -119,7 +120,7 @@ def mock_config_entry_v1_2(token_entry: dict[str, Any]) -> MockConfigEntry: ) -@pytest.fixture +@pytest.fixture(autouse=True) async def setup_credentials(hass: HomeAssistant) -> None: """Fixture to setup credentials.""" assert await async_setup_component(hass, "application_credentials", {}) @@ -147,6 +148,7 @@ async def mock_integration_setup( config_entry.add_to_hass(hass) async def run(client: MagicMock) -> bool: + assert config_entry.state is ConfigEntryState.NOT_LOADED with ( patch("homeassistant.components.home_connect.PLATFORMS", platforms), patch( diff --git a/tests/components/home_connect/fixtures/appliances.json b/tests/components/home_connect/fixtures/appliances.json index 081dd44764f..3d2e236b28c 100644 --- a/tests/components/home_connect/fixtures/appliances.json +++ b/tests/components/home_connect/fixtures/appliances.json @@ -97,7 +97,7 @@ "connected": true, "type": "Hob", "enumber": "HCS000000/05", - "haId": "BOSCH-HCS000000-D00000000005" + "haId": "BOSCH-HCS000000-68A40E000000" }, { "name": "CookProcessor", @@ -106,7 +106,7 @@ "connected": true, "type": "CookProcessor", "enumber": "HCS000000/06", - "haId": "BOSCH-HCS000000-D00000000006" + "haId": "123456789012345678" }, { "name": "DNE", diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index 535119b941c..e18489d5220 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -1,6 +1,26 @@ # serializer version: 1 # name: test_async_get_config_entry_diagnostics dict({ + '123456789012345678': dict({ + 'brand': 'BOSCH', + 'connected': True, + 'e_number': 'HCS000000/06', + 'ha_id': '123456789012345678', + 'name': 'CookProcessor', + 'programs': list([ + ]), + 'settings': dict({ + }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CookProcessor', + 'vib': 'HCS000006', + }), 'BOSCH-000000000-000000000000': dict({ 'brand': 'BOSCH', 'connected': True, @@ -21,6 +41,26 @@ 'type': 'DNE', 'vib': 'HCS000000', }), + 'BOSCH-HCS000000-68A40E000000': dict({ + 'brand': 'BOSCH', + 'connected': True, + 'e_number': 'HCS000000/05', + 'ha_id': 'BOSCH-HCS000000-68A40E000000', + 'name': 'Hob', + 'programs': list([ + ]), + 'settings': dict({ + }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hob', + 'vib': 'HCS000005', + }), 'BOSCH-HCS000000-D00000000001': dict({ 'brand': 'BOSCH', 'connected': True, @@ -114,46 +154,6 @@ 'type': 'Hood', 'vib': 'HCS000004', }), - 'BOSCH-HCS000000-D00000000005': dict({ - 'brand': 'BOSCH', - 'connected': True, - 'e_number': 'HCS000000/05', - 'ha_id': 'BOSCH-HCS000000-D00000000005', - 'name': 'Hob', - 'programs': list([ - ]), - 'settings': dict({ - }), - 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', - }), - 'type': 'Hob', - 'vib': 'HCS000005', - }), - 'BOSCH-HCS000000-D00000000006': dict({ - 'brand': 'BOSCH', - 'connected': True, - 'e_number': 'HCS000000/06', - 'ha_id': 'BOSCH-HCS000000-D00000000006', - 'name': 'CookProcessor', - 'programs': list([ - ]), - 'settings': dict({ - }), - 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', - }), - 'type': 'CookProcessor', - 'vib': 'HCS000006', - }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ 'brand': 'BOSCH', 'connected': True, diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 509003ad931..a88c8954c64 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -40,33 +40,19 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -async def test_binary_sensors( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test binary sensor entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance: HomeAppliance, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device @@ -117,15 +103,14 @@ async def test_paired_depaired_devices_flow( indirect=["appliance"], ) async def test_connected_devices( - appliance: HomeAppliance, - keys_to_check: tuple, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -142,9 +127,8 @@ async def test_connected_devices( return get_status_original_mock.return_value client.get_status = AsyncMock(side_effect=get_status_side_effect) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_status = get_status_original_mock device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) @@ -184,19 +168,17 @@ async def test_connected_devices( @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_binary_sensors_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test if binary sensor entities availability are based on the appliance connection state.""" entity_ids = [ "binary_sensor.washer_remote_control", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -283,21 +265,19 @@ async def test_binary_sensors_entity_availability( indirect=["appliance"], ) async def test_binary_sensors_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, event_key: EventKey, event_value_update: str, appliance: HomeAppliance, expected: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Tests for Home Connect Fridge appliance door states.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await client.add_events( [ EventMessage( @@ -325,17 +305,15 @@ async def test_binary_sensors_functionality( @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_sensor_functionality( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test if the connected binary sensor reports the right values.""" entity_id = "binary_sensor.washer_connectivity" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.is_state(entity_id, STATE_ON) diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py index c96fe840238..ee4d5f1d729 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -32,34 +32,19 @@ def platforms() -> list[str]: return [Platform.BUTTON] -async def test_buttons( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test button entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance: HomeAppliance, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device @@ -110,15 +95,14 @@ async def test_paired_depaired_devices_flow( indirect=["appliance"], ) async def test_connected_devices( - appliance: HomeAppliance, - keys_to_check: tuple, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -146,9 +130,8 @@ async def test_connected_devices( side_effect=get_available_commands_side_effect ) 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 + assert config_entry.state is ConfigEntryState.LOADED client.get_available_commands = get_available_commands_original_mock client.get_all_programs = get_all_programs_mock @@ -188,10 +171,9 @@ async def test_connected_devices( @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_button_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" @@ -199,9 +181,8 @@ async def test_button_entity_availability( "button.washer_pause_program", "button.washer_stop_program", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -253,19 +234,17 @@ async def test_button_entity_availability( ) async def test_button_functionality( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, entity_id: str, method_call: str, expected_kwargs: dict[str, Any], appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity = hass.states.get(entity_id) assert entity @@ -282,10 +261,9 @@ async def test_button_functionality( async def test_command_button_exception( hass: HomeAssistant, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test if button entities availability are based on the appliance connection state.""" entity_id = "button.washer_pause_program" @@ -300,9 +278,8 @@ async def test_command_button_exception( ] ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity = hass.states.get(entity_id) assert entity @@ -319,17 +296,15 @@ async def test_command_button_exception( async def test_stop_program_button_exception( hass: HomeAssistant, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test if button entities availability are based on the appliance connection state.""" entity_id = "button.washer_stop_program" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity = hass.states.get(entity_id) assert entity diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 19182a12194..ad35f890528 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -5,18 +5,16 @@ from http import HTTPStatus from unittest.mock import MagicMock, patch from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from aiohomeconnect.model import HomeAppliance import pytest from homeassistant import config_entries, setup -from homeassistant.components.application_credentials import ( - ClientCredential, - 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 homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN @@ -27,6 +25,39 @@ from tests.typing import ClientSessionGenerator CLIENT_ID = "1234" CLIENT_SECRET = "5678" +DHCP_DISCOVERY = ( + DhcpServiceInfo( + ip="1.1.1.1", + hostname="balay-dishwasher-000000000000000000", + macaddress="C8:D7:78:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="BOSCH-ABCDE1234-68A40E000000", + macaddress="68:A4:0E:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="SIEMENS-ABCDE1234-68A40E000000", + macaddress="68:A4:0E:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="SIEMENS-ABCDE1234-38B4D3000000", + macaddress="38:B4:D3:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="siemens-dishwasher-000000000000000000", + macaddress="68:A4:0E:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="siemens-dishwasher-000000000000000000", + macaddress="38:B4:D3:00:00:00", + ), +) + @pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( @@ -37,10 +68,6 @@ async def test_full_flow( """Check full flow.""" 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} ) @@ -96,10 +123,6 @@ async def test_prevent_reconfiguring_same_account( 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} ) @@ -136,21 +159,20 @@ async def test_prevent_reconfiguring_same_account( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert result["type"] == "abort" + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_flow( hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + 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 @@ -191,21 +213,20 @@ async def test_reauth_flow( assert entry.state is ConfigEntryState.LOADED assert len(mock_setup_entry.mock_calls) == 1 - assert result["type"] == FlowResultType.ABORT + assert result["type"] is 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, + 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 @@ -242,5 +263,214 @@ async def test_reauth_flow_with_different_account( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "wrong_account" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_zeroconf_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test zeroconf flow.""" + assert await setup.async_setup_component(hass, "home_connect", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "oauth_discovery" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + 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, + }, + ) + + with patch( + "homeassistant.components.home_connect.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_zeroconf_flow_already_setup( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery with already setup device.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DHCP_DISCOVERY[0], + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize("dhcp_discovery", DHCP_DISCOVERY) +async def test_dhcp_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + dhcp_discovery: DhcpServiceInfo, +) -> None: + """Test DHCP discovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_discovery + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "oauth_discovery" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + 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, + }, + ) + + with patch( + "homeassistant.components.home_connect.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_dhcp_flow_already_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery with already setup device.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY[0] + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("dhcp_discovery", "appliance"), + [ + ( + DhcpServiceInfo( + ip="1.1.1.1", + hostname="bosch-cookprocessor-123456789012345678", + macaddress="c8:d7:78:00:00:00", + ), + "CookProcessor", + ), + ( + DhcpServiceInfo( + ip="1.1.1.1", + hostname="BOSCH-HCS000000-68A40E000000", + macaddress="68:a4:0e:00:00:00", + ), + "Hob", + ), + ], + indirect=["appliance"], +) +async def test_dhcp_flow_complete_device_information( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + dhcp_discovery: DhcpServiceInfo, + appliance: HomeAppliance, +) -> None: + """Test DHCP discovery with complete device information.""" + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) + assert device + assert device.connections == set() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_discovery + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) + assert device + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, dhcp_discovery.macaddress) + } diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 31bb6d8d6a7..f9fed995b89 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -79,42 +79,14 @@ def platforms() -> list[str]: return [Platform.SENSOR, Platform.SWITCH] -async def test_coordinator_update( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test that the coordinator can update.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - -async def test_coordinator_update_failing_get_appliances( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, -) -> None: - """Test that the coordinator raises ConfigEntryNotReady when it fails to get appliances.""" - client_with_exception.get_home_appliances.return_value = None - client_with_exception.get_home_appliances.side_effect = HomeConnectError() - - assert config_entry.state == ConfigEntryState.NOT_LOADED - await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.SETUP_RETRY - - -@pytest.mark.usefixtures("setup_credentials") @pytest.mark.parametrize("platforms", [("binary_sensor",)]) @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_coordinator_failure_refresh_and_stream( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - client: MagicMock, - freezer: FrozenDateTimeFactory, appliance: HomeAppliance, ) -> None: """Test entity available state via coordinator refresh and event stream.""" @@ -127,7 +99,7 @@ async def test_coordinator_failure_refresh_and_stream( entity_id_2 = "binary_sensor.washer_remote_start" await async_setup_component(hass, HA_DOMAIN, {}) await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED state = hass.states.get(entity_id_1) assert state assert state.state != STATE_UNAVAILABLE @@ -238,18 +210,16 @@ async def test_coordinator_failure_refresh_and_stream( indirect=True, ) async def test_coordinator_not_fetching_on_disconnected_appliance( + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test that the coordinator does not fetch anything on disconnected appliance.""" appliance.connected = False - assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for method in INITIAL_FETCH_CLIENT_METHODS: assert getattr(client, method).call_count == 0 @@ -260,11 +230,10 @@ async def test_coordinator_not_fetching_on_disconnected_appliance( INITIAL_FETCH_CLIENT_METHODS, ) async def test_coordinator_update_failing( - mock_method: str, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + mock_method: str, ) -> None: """Test that although is not possible to get settings and status, the config entry is loaded. @@ -272,13 +241,13 @@ async def test_coordinator_update_failing( """ setattr(client, mock_method, AsyncMock(side_effect=HomeConnectError())) - assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED getattr(client, mock_method).assert_called() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) @pytest.mark.parametrize( ("event_type", "event_key", "event_value", ATTR_ENTITY_ID), @@ -304,25 +273,23 @@ async def test_coordinator_update_failing( ], ) async def test_event_listener( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, event_type: EventType, event_key: EventKey, event_value: str, entity_id: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance: HomeAppliance, - entity_registry: er.EntityRegistry, ) -> None: """Test that the event listener works.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED state = hass.states.get(entity_id) - + assert state event_message = EventMessage( appliance.ha_id, event_type, @@ -344,8 +311,7 @@ async def test_event_listener( new_state = hass.states.get(entity_id) assert new_state - if state is not None: - assert new_state.state != state.state + assert new_state.state != state.state # Following, we are gonna check that the listeners are clean up correctly new_entity_id = entity_id + "_new" @@ -359,7 +325,7 @@ async def test_event_listener( def event_filter(_: EventStateReportedData) -> bool: return True - hass.bus.async_listen(EVENT_STATE_REPORTED, listener_callback, event_filter) + hass.bus.async_listen_once(EVENT_STATE_REPORTED, listener_callback, event_filter) entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id) await hass.async_block_till_done() @@ -375,19 +341,17 @@ async def test_event_listener( @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def tests_receive_setting_and_status_for_first_time_at_events( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test that the event listener is capable of receiving settings and status for the first time.""" client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) client.get_status = AsyncMock(return_value=ArrayOfStatus([])) - assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await client.add_events( [ @@ -427,15 +391,14 @@ async def tests_receive_setting_and_status_for_first_time_at_events( ) await hass.async_block_till_done() assert len(config_entry._background_tasks) == 1 - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED async def test_event_listener_error( hass: HomeAssistant, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test that the configuration entry is reloaded when the event stream raises an API error.""" client_with_exception.stream_all_events = MagicMock( @@ -454,7 +417,6 @@ async def test_event_listener_error( assert not config_entry._background_tasks -@pytest.mark.usefixtures("setup_credentials") @pytest.mark.parametrize("platforms", [("sensor",)]) @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( @@ -480,17 +442,17 @@ async def test_event_listener_error( ], ) async def test_event_listener_resilience( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + exception: HomeConnectError, entity_id: str, initial_state: str, event_key: EventKey, event_value: Any, after_event_expected_state: str, - exception: HomeConnectError, - hass: HomeAssistant, - appliance: HomeAppliance, - client: MagicMock, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], ) -> None: """Test that the event listener is resilient to interruptions.""" future = hass.loop.create_future() @@ -502,11 +464,10 @@ async def test_event_listener_resilience( side_effect=[stream_exception(), client.stream_all_events()] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert len(config_entry._background_tasks) == 1 state = hass.states.get(entity_id) @@ -550,11 +511,10 @@ async def test_event_listener_resilience( async def test_devices_updated_on_refresh( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, ) -> None: """Test handling of devices added or deleted while event stream is down.""" appliances: list[HomeAppliance] = ( @@ -566,9 +526,8 @@ async def test_devices_updated_on_refresh( ) await async_setup_component(hass, HA_DOMAIN, {}) - assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for appliance in appliances[:2]: assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) @@ -592,17 +551,15 @@ async def test_devices_updated_on_refresh( @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_disconnected_devices_not_fetching( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test that Home Connect API is not fetched after pairing a disconnected device.""" client.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances([])) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED appliance.connected = False await client.add_events( @@ -623,12 +580,11 @@ async def test_paired_disconnected_devices_not_fetching( async def test_coordinator_disabling_updates_for_appliance( hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + client: MagicMock, 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. @@ -638,9 +594,8 @@ async def test_coordinator_disabling_updates_for_appliance( 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 config_entry.state is ConfigEntryState.LOADED assert hass.states.is_state("switch.dishwasher_power", STATE_ON) @@ -717,12 +672,10 @@ async def test_coordinator_disabling_updates_for_appliance( async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + client: MagicMock, 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. @@ -731,9 +684,8 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r 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 config_entry.state is ConfigEntryState.LOADED assert hass.states.is_state("switch.dishwasher_power", STATE_ON) @@ -757,9 +709,8 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r 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 + assert config_entry.state is ConfigEntryState.LOADED get_settings_original_side_effect = client.get_settings.side_effect diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index ab6823411dc..858f331a33d 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -19,33 +19,29 @@ from tests.common import MockConfigEntry async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot async def test_async_get_device_diagnostics( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test device config entry diagnostics.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index e91a01a907a..61a0c4005fb 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -95,6 +95,10 @@ def platforms() -> list[str]: indirect=["appliance"], ) async def test_program_options_retrieval( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], array_of_programs_program_arg: str, event_key: EventKey, appliance: HomeAppliance, @@ -103,11 +107,6 @@ async def test_program_options_retrieval( options_availability_stage_2: list[bool], option_without_default: tuple[OptionKey, str], option_without_constraints: tuple[OptionKey, str], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test that the options are correctly retrieved at the start and updated on program updates.""" original_get_all_programs_mock = client.get_all_programs.side_effect @@ -158,9 +157,8 @@ async def test_program_options_retrieval( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id, (state, _) in zip( option_entity_id.values(), options_state_stage_1, strict=True @@ -251,14 +249,13 @@ async def test_program_options_retrieval( ], ) async def test_no_options_retrieval_on_unknown_program( - array_of_programs_program_arg: str, - event_key: EventKey, - appliance: HomeAppliance, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + appliance: HomeAppliance, + array_of_programs_program_arg: str, + event_key: EventKey, ) -> None: """Test that no options are retrieved when the program is unknown.""" @@ -278,9 +275,8 @@ async def test_no_options_retrieval_on_unknown_program( client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert client.get_available_program.call_count == 0 @@ -328,15 +324,14 @@ async def test_no_options_retrieval_on_unknown_program( indirect=["appliance"], ) async def test_program_options_retrieval_after_appliance_connection( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], event_key: EventKey, appliance: HomeAppliance, option_key: OptionKey, option_entity_id: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test that the options are correctly retrieved at the start and updated on program updates.""" array_of_home_appliances = client.get_home_appliances.return_value @@ -360,9 +355,8 @@ async def test_program_options_retrieval_after_appliance_connection( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert not hass.states.get(option_entity_id) @@ -450,13 +444,12 @@ async def test_program_options_retrieval_after_appliance_connection( ], ) async def test_option_entity_functionality_exception( - set_active_program_option_side_effect: HomeConnectError | None, - set_selected_program_option_side_effect: HomeConnectError | None, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + set_active_program_option_side_effect: HomeConnectError | None, + set_selected_program_option_side_effect: HomeConnectError | None, ) -> None: """Test that the option entity handles exceptions correctly.""" entity_id = "switch.washer_i_dos_1_active" @@ -473,9 +466,8 @@ async def test_option_entity_functionality_exception( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get(entity_id) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 2147d9b170a..2820eea3031 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -41,43 +41,28 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_entry_setup( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test setup and unload.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.NOT_LOADED - - -async def test_exception_handling( - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - client_with_exception: MagicMock, -) -> None: - """Test exception handling.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED @pytest.mark.parametrize("token_expiration_time", [12345]) async def test_token_refresh_success( hass: HomeAssistant, - platforms: list[Platform], + aioclient_mock: AiohttpClientMocker, + client: MagicMock, integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_credentials: None, - client: MagicMock, + platforms: list[Platform], ) -> None: """Test where token is expired and the refresh attempt succeeds.""" @@ -100,7 +85,7 @@ async def test_token_refresh_success( client._auth = auth return client - assert config_entry.state == ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED with ( patch("homeassistant.components.home_connect.PLATFORMS", platforms), patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock, @@ -108,7 +93,7 @@ async def test_token_refresh_success( client_mock.side_effect = MagicMock(side_effect=init_side_effect) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED # Verify token request assert aioclient_mock.call_count == 1 @@ -152,15 +137,13 @@ async def test_token_refresh_success( ], ) async def test_token_refresh_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], aioclient_mock_args: dict[str, Any], expected_config_entry_state: ConfigEntryState, - hass: HomeAssistant, - platforms: list[Platform], - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_credentials: None, - client: MagicMock, ) -> None: """Test where token is expired and the refresh attempt fails.""" @@ -171,7 +154,7 @@ async def test_token_refresh_error( **aioclient_mock_args, ) - assert config_entry.state == ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED with patch( "homeassistant.components.home_connect.HomeConnectClient", return_value=client ): @@ -189,17 +172,15 @@ async def test_token_refresh_error( ], ) async def test_client_error( - exception: HomeConnectError, - expected_state: ConfigEntryState, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, + exception: HomeConnectError, + expected_state: ConfigEntryState, ) -> None: """Test client errors during setup integration.""" client_with_exception.get_home_appliances.return_value = None client_with_exception.get_home_appliances.side_effect = exception - assert config_entry.state == ConfigEntryState.NOT_LOADED assert not await integration_setup(client_with_exception) assert config_entry.state == expected_state assert client_with_exception.get_home_appliances.call_count == 1 @@ -216,12 +197,10 @@ async def test_client_error( ], ) async def test_client_rate_limit_error( - raising_exception_method: str, - hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + raising_exception_method: str, ) -> None: """Test client errors during setup integration.""" retry_after = 42 @@ -237,12 +216,12 @@ async def test_client_rate_limit_error( mock.side_effect = side_effect setattr(client, raising_exception_method, mock) - assert config_entry.state == ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED with patch( "homeassistant.components.home_connect.coordinator.asyncio_sleep", ) as asyncio_sleep_mock: assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert mock.call_count >= 2 asyncio_sleep_mock.assert_called_once_with(retry_after) @@ -251,17 +230,15 @@ async def test_client_rate_limit_error( async def test_required_program_or_at_least_an_option( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: "Test that the set_program_and_options does raise an exception if no program nor options are set." - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -288,8 +265,8 @@ async def test_entity_migration( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance: HomeAppliance, platforms: list[Platform], + appliance: HomeAppliance, ) -> None: """Test entity migration.""" diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 298eead1737..b467dd2a7d2 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -53,33 +53,19 @@ def platforms() -> list[str]: return [Platform.LIGHT] -async def test_light( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test switch entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - @pytest.mark.parametrize("appliance", ["Hood"], indirect=True) async def test_paired_depaired_devices_flow( - appliance: HomeAppliance, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device @@ -130,15 +116,14 @@ async def test_paired_depaired_devices_flow( indirect=["appliance"], ) async def test_connected_devices( - appliance: HomeAppliance, - keys_to_check: tuple, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -155,9 +140,8 @@ async def test_connected_devices( return await get_settings_original_mock.side_effect(ha_id) client.get_settings = AsyncMock(side_effect=get_settings_side_effect) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_settings = get_settings_original_mock device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) @@ -191,19 +175,17 @@ async def test_connected_devices( @pytest.mark.parametrize("appliance", ["Hood"], indirect=True) async def test_light_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test if light entities availability are based on the appliance connection state.""" entity_ids = [ "light.hood_functional_light", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -356,22 +338,20 @@ async def test_light_availability( indirect=["appliance"], ) async def test_light_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, set_settings_args: dict[SettingKey, Any], service: str, exprected_attributes: dict[str, Any], state: str, appliance: HomeAppliance, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test light functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED service_data = exprected_attributes.copy() service_data[ATTR_ENTITY_ID] = entity_id @@ -412,19 +392,17 @@ async def test_light_functionality( indirect=["appliance"], ) async def test_light_color_different_than_custom( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, events: dict[EventKey, Any], appliance: HomeAppliance, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test that light color attributes are not set if color is different than custom.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -577,17 +555,16 @@ async def test_light_color_different_than_custom( ], ) async def test_light_exception_handling( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, setting: dict[SettingKey, dict[str, Any]], service: str, service_data: dict, attr_side_effect: list[type[HomeConnectError] | None], exception_match: str, - hass: HomeAssistant, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test light exception handling.""" client_with_exception.get_settings.side_effect = None @@ -604,9 +581,8 @@ async def test_light_exception_handling( client_with_exception.set_setting.side_effect = [ exception() if exception else None for exception in attr_side_effect ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index 7e89f66683b..58d6dae2900 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -58,28 +58,15 @@ def platforms() -> list[str]: return [Platform.NUMBER] -async def test_number( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test number entity.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance: HomeAppliance, hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" client.get_available_program = AsyncMock( @@ -93,9 +80,8 @@ async def test_paired_depaired_devices_flow( ], ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device @@ -148,15 +134,14 @@ async def test_paired_depaired_devices_flow( indirect=["appliance"], ) async def test_connected_devices( - appliance: HomeAppliance, - keys_to_check: tuple, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -173,9 +158,8 @@ async def test_connected_devices( return get_settings_original_mock.return_value client.get_settings = AsyncMock(side_effect=get_settings_side_effect) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_settings = get_settings_original_mock device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) @@ -209,10 +193,9 @@ async def test_connected_devices( @pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) async def test_number_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test if number entities availability are based on the appliance connection state.""" @@ -224,9 +207,8 @@ async def test_number_entity_availability( # Setting constrains are not needed for this test # so we rise an error to easily test the availability client.get_setting = AsyncMock(side_effect=HomeConnectError()) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -300,6 +282,10 @@ async def test_number_entity_availability( ], ) async def test_number_entity_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, @@ -309,11 +295,6 @@ async def test_number_entity_functionality( max_value: int, step_size: float, unit_of_measurement: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test number entity functionality.""" client.get_setting.side_effect = None @@ -332,7 +313,6 @@ async def test_number_entity_functionality( ) ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED entity_state = hass.states.get(entity_id) @@ -388,6 +368,10 @@ async def test_number_entity_functionality( ) @patch("homeassistant.components.home_connect.entity.API_DEFAULT_RETRY_AFTER", new=0) async def test_fetch_constraints_after_rate_limit_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], retry_after: int | None, appliance: HomeAppliance, entity_id: str, @@ -397,11 +381,6 @@ async def test_fetch_constraints_after_rate_limit_error( max_value: int, step_size: int, unit_of_measurement: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test that, if a API rate limit error is raised, the constraints are fetched later.""" @@ -437,7 +416,6 @@ async def test_fetch_constraints_after_rate_limit_error( ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -465,14 +443,13 @@ async def test_fetch_constraints_after_rate_limit_error( ], ) async def test_number_entity_error( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, setting_key: SettingKey, mock_attr: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test number entity error.""" client_with_exception.get_settings.side_effect = None @@ -490,7 +467,6 @@ async def test_number_entity_error( ) ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED @@ -547,6 +523,10 @@ async def test_number_entity_error( indirect=["appliance"], ) async def test_options_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, option_key: OptionKey, appliance: HomeAppliance, @@ -557,11 +537,6 @@ async def test_options_functionality( set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test options functionality.""" @@ -618,9 +593,8 @@ async def test_options_functionality( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity_state = hass.states.get(entity_id) assert entity_state assert entity_state.attributes["unit_of_measurement"] == unit diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 6d8c090571e..a4263808276 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -62,28 +62,15 @@ def platforms() -> list[str]: return [Platform.SELECT] -async def test_select( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test select entity.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance: HomeAppliance, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" client.get_available_program = AsyncMock( @@ -97,9 +84,8 @@ async def test_paired_depaired_devices_flow( ], ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device @@ -154,15 +140,14 @@ async def test_paired_depaired_devices_flow( indirect=["appliance"], ) async def test_connected_devices( - appliance: HomeAppliance, - keys_to_check: tuple, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -188,9 +173,8 @@ async def test_connected_devices( client.get_settings = AsyncMock(side_effect=get_settings_side_effect) 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 + assert config_entry.state is ConfigEntryState.LOADED client.get_settings = get_settings_original_mock client.get_all_programs = get_all_programs_mock @@ -225,19 +209,17 @@ async def test_connected_devices( @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_select_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test if select entities availability are based on the appliance connection state.""" entity_ids = [ "select.washer_active_program", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -276,11 +258,10 @@ async def test_select_entity_availability( async def test_filter_programs( + entity_registry: er.EntityRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - entity_registry: er.EntityRegistry, ) -> None: """Test select that only right programs are shown.""" client.get_all_programs.side_effect = None @@ -314,7 +295,6 @@ async def test_filter_programs( ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -368,6 +348,10 @@ async def test_filter_programs( indirect=["appliance"], ) async def test_select_program_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, expected_initial_state: str, @@ -375,14 +359,8 @@ async def test_select_program_functionality( program_key: ProgramKey, program_to_set: str, event_key: EventKey, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test select functionality.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -445,15 +423,14 @@ async def test_select_program_functionality( ], ) async def test_select_exception_handling( + hass: HomeAssistant, + client_with_exception: MagicMock, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + config_entry: MockConfigEntry, entity_id: str, program_to_set: str, mock_attr: str, exception_match: str, - hass: HomeAssistant, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test exception handling.""" client_with_exception.get_all_programs.side_effect = None @@ -466,7 +443,6 @@ async def test_select_exception_handling( ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED @@ -488,12 +464,11 @@ async def test_select_exception_handling( @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_programs_updated_on_connect( - appliance: HomeAppliance, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + appliance: HomeAppliance, ) -> None: """Test that devices reconnected. @@ -513,9 +488,8 @@ async def test_programs_updated_on_connect( 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 + assert config_entry.state is ConfigEntryState.LOADED client.get_all_programs = get_all_programs_mock state = hass.states.get("select.washer_active_program") @@ -573,20 +547,18 @@ async def test_programs_updated_on_connect( ], ) async def test_select_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, expected_options: set[str], value_to_set: str, expected_value_call_arg: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test select functionality.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -637,16 +609,15 @@ async def test_select_functionality( ], ) async def test_fetch_allowed_values( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, test_setting_key: SettingKey, allowed_values: list[str | None], expected_options: set[str], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test fetch allowed values.""" original_get_setting_side_effect = client.get_setting @@ -667,7 +638,6 @@ async def test_fetch_allowed_values( client.get_setting = AsyncMock(side_effect=get_setting_side_effect) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -694,16 +664,15 @@ async def test_fetch_allowed_values( ], ) async def test_fetch_allowed_values_after_rate_limit_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, allowed_values: list[str | None], expected_options: set[str], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test fetch allowed values.""" @@ -735,7 +704,6 @@ async def test_fetch_allowed_values_after_rate_limit_error( ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -769,16 +737,15 @@ async def test_fetch_allowed_values_after_rate_limit_error( ], ) async def test_default_values_after_fetch_allowed_values_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, exception: Exception, expected_options: set[str], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test fetch allowed values.""" @@ -798,7 +765,6 @@ async def test_default_values_after_fetch_allowed_values_error( client.get_settings = AsyncMock(side_effect=get_settings_side_effect) client.get_setting = AsyncMock(side_effect=exception) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -822,16 +788,15 @@ async def test_default_values_after_fetch_allowed_values_error( ], ) async def test_select_entity_error( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, setting_key: SettingKey, allowed_value: str, value_to_set: str, mock_attr: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test select entity error.""" client_with_exception.get_settings.side_effect = None @@ -845,7 +810,6 @@ async def test_select_entity_error( ) ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED @@ -937,6 +901,10 @@ async def test_select_entity_error( ], ) async def test_options_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, option_key: OptionKey, allowed_values: list[str | None] | None, @@ -945,11 +913,6 @@ async def test_options_functionality( set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test options functionality.""" if set_active_program_options_side_effect: @@ -977,9 +940,8 @@ async def test_options_functionality( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity_state = hass.states.get(entity_id) assert entity_state assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index d48befcf73f..fe8a3ab4be0 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,7 +1,6 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable -import logging from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -89,33 +88,19 @@ def platforms() -> list[str]: return [Platform.SENSOR] -async def test_sensors( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test sensor entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance: HomeAppliance, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device @@ -154,29 +139,6 @@ 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"), @@ -189,15 +151,14 @@ async def test_paired_depaired_devices_flow( indirect=["appliance"], ) async def test_connected_devices( - appliance: HomeAppliance, - keys_to_check: tuple, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -214,9 +175,8 @@ async def test_connected_devices( return get_status_original_mock.return_value client.get_status = AsyncMock(side_effect=get_status_side_effect) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_status = get_status_original_mock device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) @@ -247,13 +207,13 @@ async def test_connected_devices( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) async def test_sensor_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test if sensor entities availability are based on the appliance connection state.""" @@ -261,31 +221,8 @@ async def test_sensor_entity_availability( "sensor.dishwasher_operation_state", "sensor.dishwasher_salt_nearly_empty", ] - 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.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() + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -370,15 +307,14 @@ ENTITY_ID_STATES = { ), ) async def test_program_sensors( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, states: tuple, event_run: dict[EventType, dict[EventKey, str | int]], - freezer: FrozenDateTimeFactory, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, ) -> None: """Test sequence for sensors that expose information about a program.""" entity_ids = ENTITY_ID_STATES.keys() @@ -386,7 +322,7 @@ async def test_program_sensors( time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - assert config_entry.state == ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED client.get_status.return_value.status.extend( Status( key=StatusKey(event_key.value), @@ -396,7 +332,7 @@ async def test_program_sensors( for event_key, value in EVENT_PROG_DELAYED_START[EventType.STATUS].items() ) assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await client.add_events( [ @@ -444,16 +380,15 @@ async def test_program_sensors( ], ) async def test_program_sensor_edge_case( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], initial_operation_state: str, initial_state: str, event_order: tuple[EventType, EventType], entity_states: tuple[str, str], appliance: HomeAppliance, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test edge case for the program related entities.""" entity_id = "sensor.dishwasher_program_progress" @@ -469,9 +404,8 @@ async def test_program_sensor_edge_case( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.is_state(entity_id, initial_state) @@ -520,22 +454,20 @@ ENTITY_ID_EDGE_CASE_STATES = [ @pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance: HomeAppliance, - freezer: FrozenDateTimeFactory, hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + appliance: HomeAppliance, ) -> None: """Run program sequence to test edge cases for the remaining_prog_time entity.""" entity_id = "sensor.dishwasher_program_finish_time" time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for ( event, @@ -568,152 +500,146 @@ async def test_remaining_prog_time_edge_cases( assert hass.states.is_state(entity_id, expected_state) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ( "entity_id", "event_key", - "value_expected_state", + "event_type", + "event_value_update", + "expected", "appliance", ), [ ( "sensor.dishwasher_door", EventKey.BSH_COMMON_STATUS_DOOR_STATE, - [ - ( - BSH_DOOR_STATE_LOCKED, - "locked", - ), - ( - BSH_DOOR_STATE_CLOSED, - "closed", - ), - ( - BSH_DOOR_STATE_OPEN, - "open", - ), - ], + EventType.STATUS, + BSH_DOOR_STATE_LOCKED, + "locked", "Dishwasher", ), - ], - indirect=["appliance"], -) -async def test_sensors_states( - entity_id: str, - event_key: EventKey, - value_expected_state: list[tuple[str, str]], - appliance: HomeAppliance, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Tests for appliance sensors.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - 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_state) - - -@pytest.mark.parametrize( - ( - "entity_id", - "event_key", - "appliance", - ), - [ + ( + "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_event_sensors_states( - entity_id: str, - event_key: EventKey, - appliance: HomeAppliance, +async def test_sensors_states( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - entity_registry: er.EntityRegistry, - caplog: pytest.LogCaptureFixture, + entity_id: str, + event_key: EventKey, + event_type: EventType, + event_value_update: str, + appliance: HomeAppliance, + expected: str, ) -> None: - """Tests for appliance event sensors.""" - caplog.set_level(logging.ERROR) - assert config_entry.state == ConfigEntryState.NOT_LOADED + """Tests for appliance alarm sensors.""" assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is 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 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, + ) + ], ), - ] - ) - 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 + ), + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected) @pytest.mark.parametrize( @@ -746,17 +672,16 @@ async def test_event_sensors_states( indirect=["appliance"], ) async def test_sensor_unit_fetching( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, status_key: StatusKey, unit_get_status: str | None, unit_get_status_value: str | None, get_status_value_call_count: int, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test that the sensor entities are capable of fetching units.""" @@ -784,9 +709,8 @@ async def test_sensor_unit_fetching( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity_state = hass.states.get(entity_id) assert entity_state @@ -814,14 +738,13 @@ async def test_sensor_unit_fetching( indirect=["appliance"], ) async def test_sensor_unit_fetching_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, status_key: StatusKey, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test that the sensor entities are capable of fetching units.""" @@ -841,9 +764,8 @@ async def test_sensor_unit_fetching_error( client.get_status = AsyncMock(side_effect=get_status_mock) client.get_status_value = AsyncMock(side_effect=HomeConnectError()) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get(entity_id) @@ -866,15 +788,14 @@ async def test_sensor_unit_fetching_error( indirect=["appliance"], ) async def test_sensor_unit_fetching_after_rate_limit_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, status_key: StatusKey, unit: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test that the sensor entities are capable of fetching units.""" @@ -904,11 +825,10 @@ async def test_sensor_unit_fetching_after_rate_limit_error( ] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) async_fire_time_changed(hass) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert client.get_status_value.call_count == 2 diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py index 2915cbe4f69..33a7f7aee71 100644 --- a/tests/components/home_connect/test_services.py +++ b/tests/components/home_connect/test_services.py @@ -176,19 +176,17 @@ SERVICES_SET_PROGRAM_AND_OPTIONS = [ SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) async def test_key_value_services( - service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, + service_call: dict[str, Any], ) -> None: """Create and test services.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -225,22 +223,20 @@ async def test_key_value_services( ], ) async def test_programs_and_options_actions_deprecation( - service_call: dict[str, Any], - issue_id: str, hass: HomeAssistant, + hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, + service_call: dict[str, Any], + issue_id: str, ) -> None: """Test deprecated service keys.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -296,21 +292,19 @@ async def test_programs_and_options_actions_deprecation( ), ) async def test_set_program_and_options( - service_call: dict[str, Any], - called_method: str, hass: HomeAssistant, device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, + service_call: dict[str, Any], + called_method: str, snapshot: SnapshotAssertion, ) -> None: """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -340,20 +334,18 @@ async def test_set_program_and_options( ), ) async def test_set_program_and_options_exceptions( - service_call: dict[str, Any], - error_regex: str, hass: HomeAssistant, device_registry: dr.DeviceRegistry, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, appliance: HomeAppliance, + service_call: dict[str, Any], + error_regex: str, ) -> None: """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -371,19 +363,17 @@ async def test_set_program_and_options_exceptions( SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) async def test_services_exception_device_id( - service_call: dict[str, Any], hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, appliance: HomeAppliance, - device_registry: dr.DeviceRegistry, + service_call: dict[str, Any], ) -> None: """Raise a HomeAssistantError when there is an API error.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -398,16 +388,14 @@ async def test_services_exception_device_id( async def test_services_appliance_not_found( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, ) -> None: """Raise a ServiceValidationError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED service_call = SERVICE_KV_CALL_PARAMS[0] @@ -445,19 +433,17 @@ async def test_services_appliance_not_found( SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) async def test_services_exception( - service_call: dict[str, Any], hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, appliance: HomeAppliance, - device_registry: dr.DeviceRegistry, + service_call: dict[str, Any], ) -> None: """Raise a ValueError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 2f8b95ceab2..1131f0ab46e 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -1,16 +1,12 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, - ArrayOfPrograms, ArrayOfSettings, - Event, - EventKey, EventMessage, EventType, GetSetting, @@ -26,19 +22,16 @@ from aiohomeconnect.model.error import ( HomeConnectError, SelectedProgramNotSetError, ) -from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption +from aiohomeconnect.model.program import ProgramDefinitionOption from aiohomeconnect.model.setting import SettingConstraints import pytest -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -52,15 +45,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -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.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator @pytest.fixture @@ -69,45 +56,19 @@ def platforms() -> list[str]: return [Platform.SWITCH] -async def test_switches( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test switch entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance: HomeAppliance, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - client.get_available_program = AsyncMock( - return_value=ProgramDefinition( - ProgramKey.UNKNOWN, - options=[ - ProgramDefinitionOption( - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, - "Boolean", - ) - ], - ) - ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device @@ -155,22 +116,20 @@ async def test_paired_depaired_devices_flow( ( SettingKey.BSH_COMMON_POWER_STATE, SettingKey.BSH_COMMON_CHILD_LOCK, - "Program Cotton", ), ) ], indirect=["appliance"], ) async def test_connected_devices( - appliance: HomeAppliance, - keys_to_check: tuple, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -178,7 +137,6 @@ async def test_connected_devices( not be obtained while disconnected and once connected, the entities are added. """ get_settings_original_mock = client.get_settings - get_all_programs_mock = client.get_all_programs async def get_settings_side_effect(ha_id: str): if ha_id == appliance.ha_id: @@ -187,20 +145,10 @@ async def test_connected_devices( ) return await get_settings_original_mock.side_effect(ha_id) - async def get_all_programs_side_effect(ha_id: str): - if ha_id == appliance.ha_id: - raise HomeConnectApiError( - "SDK.Error.HomeAppliance.Connection.Initialization.Failed" - ) - return await get_all_programs_mock.side_effect(ha_id) - client.get_settings = AsyncMock(side_effect=get_settings_side_effect) - 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 + assert config_entry.state is ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - client.get_all_programs = get_all_programs_mock device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device @@ -234,21 +182,18 @@ async def test_connected_devices( @pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) async def test_switch_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test if switch entities availability are based on the appliance connection state.""" entity_ids = [ "switch.dishwasher_power", "switch.dishwasher_child_lock", - "switch.dishwasher_program_eco50", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -316,23 +261,21 @@ async def test_switch_entity_availability( indirect=["appliance"], ) async def test_switch_functionality( - entity_id: str, - settings_key_arg: SettingKey, - setting_value_arg: Any, - service: str, - state: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, + entity_id: str, + service: str, + settings_key_arg: SettingKey, + setting_value_arg: Any, + state: str, appliance: HomeAppliance, - client: MagicMock, ) -> None: """Test switch functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() @@ -342,84 +285,6 @@ async def test_switch_functionality( assert hass.states.is_state(entity_id, state) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize( - ("entity_id", "program_key", "initial_state", "appliance"), - [ - ( - "switch.dryer_program_mix", - ProgramKey.LAUNDRY_CARE_DRYER_MIX, - STATE_OFF, - "Dryer", - ), - ( - "switch.dryer_program_cotton", - ProgramKey.LAUNDRY_CARE_DRYER_COTTON, - STATE_ON, - "Dryer", - ), - ], - indirect=["appliance"], -) -async def test_program_switch_functionality( - entity_id: str, - program_key: ProgramKey, - initial_state: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - appliance: HomeAppliance, - client: MagicMock, -) -> None: - """Test switch functionality.""" - - async def mock_stop_program(ha_id: str) -> None: - """Mock stop program.""" - await client.add_events( - [ - EventMessage( - ha_id, - EventType.NOTIFY, - ArrayOfEvents( - [ - Event( - key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, - timestamp=0, - level="", - handling="", - value=ProgramKey.UNKNOWN, - ) - ] - ), - ), - ] - ) - - client.stop_program = AsyncMock(side_effect=mock_stop_program) - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, initial_state) - - await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, STATE_ON) - client.start_program.assert_awaited_once_with( - appliance.ha_id, program_key=program_key - ) - - await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, STATE_OFF) - client.stop_program.assert_awaited_once_with(appliance.ha_id) - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ( @@ -429,18 +294,6 @@ async def test_program_switch_functionality( "exception_match", ), [ - ( - "switch.dishwasher_program_eco50", - SERVICE_TURN_ON, - "start_program", - r"Error.*start.*program.*", - ), - ( - "switch.dishwasher_program_eco50", - SERVICE_TURN_OFF, - "stop_program", - r"Error.*stop.*program.*", - ), ( "switch.dishwasher_power", SERVICE_TURN_OFF, @@ -468,26 +321,16 @@ async def test_program_switch_functionality( ], ) async def test_switch_exception_handling( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, service: str, mock_attr: str, exception_match: str, - hass: HomeAssistant, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - client_with_exception.get_all_programs.side_effect = None - client_with_exception.get_all_programs.return_value = ArrayOfPrograms( - [ - EnumerateProgram( - key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, - raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, - ) - ] - ) client_with_exception.get_settings.side_effect = None client_with_exception.get_settings.return_value = ArrayOfSettings( [ @@ -507,9 +350,8 @@ async def test_switch_exception_handling( ] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): @@ -523,18 +365,16 @@ async def test_switch_exception_handling( @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), + ("entity_id", "service", "state", "appliance"), [ ( "switch.fridgefreezer_freezer_super_mode", - {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True}, SERVICE_TURN_ON, STATE_ON, "FridgeFreezer", ), ( "switch.fridgefreezer_freezer_super_mode", - {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False}, SERVICE_TURN_OFF, STATE_OFF, "FridgeFreezer", @@ -543,22 +383,18 @@ async def test_switch_exception_handling( indirect=["appliance"], ) async def test_ent_desc_switch_functionality( - entity_id: str, - status: dict, - service: str, - state: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - appliance: HomeAppliance, - client: MagicMock, + entity_id: str, + service: str, + state: str, ) -> None: """Test switch functionality - entity description setup.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() @@ -570,7 +406,6 @@ async def test_ent_desc_switch_functionality( "entity_id", "status", "service", - "mock_attr", "appliance", "exception_match", ), @@ -579,7 +414,6 @@ async def test_ent_desc_switch_functionality( "switch.fridgefreezer_freezer_super_mode", {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_ON, - "set_setting", "FridgeFreezer", r"Error.*turn.*on.*", ), @@ -587,7 +421,6 @@ async def test_ent_desc_switch_functionality( "switch.fridgefreezer_freezer_super_mode", {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_OFF, - "set_setting", "FridgeFreezer", r"Error.*turn.*off.*", ), @@ -595,17 +428,14 @@ async def test_ent_desc_switch_functionality( indirect=["appliance"], ) async def test_ent_desc_switch_exception_handling( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, status: dict[SettingKey, str], service: str, - mock_attr: str, exception_match: str, - hass: HomeAssistant, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - appliance: HomeAppliance, - client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" client_with_exception.get_settings.side_effect = None @@ -619,9 +449,8 @@ async def test_ent_desc_switch_exception_handling( for key, value in status.items() ] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): @@ -679,17 +508,16 @@ async def test_ent_desc_switch_exception_handling( indirect=["appliance"], ) async def test_power_switch( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, allowed_values: list[str | None] | None, service: str, setting_value_arg: str, power_state: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, appliance: HomeAppliance, - client: MagicMock, ) -> None: """Test power switch functionality.""" client.get_settings.side_effect = None @@ -706,9 +534,8 @@ async def test_power_switch( ] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() @@ -728,12 +555,11 @@ async def test_power_switch( ], ) async def test_power_switch_fetch_off_state_from_current_value( - initial_value: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + initial_value: str, ) -> None: """Test power switch functionality to fetch the off state from the current value.""" client.get_settings.side_effect = None @@ -747,9 +573,8 @@ async def test_power_switch_fetch_off_state_from_current_value( ] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) @@ -778,15 +603,14 @@ async def test_power_switch_fetch_off_state_from_current_value( ], ) async def test_power_switch_service_validation_errors( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + exception_match: str, entity_id: str, allowed_values: list[str | None] | None | HomeConnectError, service: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - exception_match: str, - client: MagicMock, ) -> None: """Test power switch functionality validation errors.""" client.get_settings.side_effect = None @@ -814,9 +638,8 @@ async def test_power_switch_service_validation_errors( client.get_settings.return_value = ArrayOfSettings([setting]) client.get_setting = AsyncMock(return_value=setting) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( @@ -824,178 +647,6 @@ async def test_power_switch_service_validation_errors( ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize( - "service", - [SERVICE_TURN_ON, SERVICE_TURN_OFF], -) -async def test_create_program_switch_deprecation_issue( - hass: HomeAssistant, - appliance: HomeAppliance, - service: str, - 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 program switch entity or the entity is used by the user.""" - entity_id = "switch.washer_program_mix" - automation_script_issue_id = f"deprecated_program_switch_{entity_id}" - action_handler_issue_id = f"deprecated_program_switch_{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": [ - { - "action": "switch.turn_on", - "entity_id": entity_id, - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - await hass.services.async_call( - SWITCH_DOMAIN, - service, - { - ATTR_ENTITY_ID: entity_id, - }, - blocking=True, - ) - - 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) == 2 - assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) - assert issue_registry.async_get_issue(DOMAIN, action_handler_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, automation_script_issue_id) - assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize( - "service", - [SERVICE_TURN_ON, SERVICE_TURN_OFF], -) -async def test_program_switch_deprecation_issue_fix( - hass: HomeAssistant, - appliance: HomeAppliance, - service: str, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test we can fix the issues created when a program switch entity is in an automation or in a script or when is used.""" - entity_id = "switch.washer_program_mix" - automation_script_issue_id = f"deprecated_program_switch_{entity_id}" - action_handler_issue_id = f"deprecated_program_switch_{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": [ - { - "action": "switch.turn_on", - "entity_id": entity_id, - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - await hass.services.async_call( - SWITCH_DOMAIN, - service, - { - ATTR_ENTITY_ID: entity_id, - }, - blocking=True, - ) - - 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) == 2 - assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) - assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) - - for issue in issue_registry.issues.copy().values(): - _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, automation_script_issue_id) - assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) - assert len(issue_registry.issues) == 0 - - @pytest.mark.parametrize( ( "set_active_program_options_side_effect", @@ -1027,17 +678,16 @@ async def test_program_switch_deprecation_issue_fix( indirect=["appliance"], ) async def test_options_functionality( - entity_id: str, - option_key: OptionKey, - appliance: HomeAppliance, + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + entity_id: str, + option_key: OptionKey, + appliance: HomeAppliance, ) -> None: """Test options functionality.""" if set_active_program_options_side_effect: @@ -1056,9 +706,8 @@ async def test_options_functionality( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get(entity_id) await hass.services.async_call( diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 34781c29eb8..9e114768b6f 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -45,34 +45,20 @@ def platforms() -> list[str]: return [Platform.TIME] -async def test_time( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test time entity.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_paired_depaired_devices_flow( - appliance: HomeAppliance, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device @@ -124,15 +110,14 @@ async def test_paired_depaired_devices_flow( indirect=["appliance"], ) async def test_connected_devices( - appliance: HomeAppliance, - keys_to_check: tuple, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -149,9 +134,8 @@ async def test_connected_devices( return await get_settings_original_mock.side_effect(ha_id) client.get_settings = AsyncMock(side_effect=get_settings_side_effect) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_settings = get_settings_original_mock device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) @@ -186,19 +170,17 @@ async def test_connected_devices( @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_time_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, appliance: HomeAppliance, ) -> None: """Test if time entities availability are based on the appliance connection state.""" entity_ids = [ "time.oven_alarm_clock", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -248,17 +230,15 @@ async def test_time_entity_availability( ], ) async def test_time_entity_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test time entity functionality.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -293,14 +273,13 @@ async def test_time_entity_functionality( ], ) async def test_time_entity_error( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, setting_key: SettingKey, mock_attr: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test time entity error.""" client_with_exception.get_settings.side_effect = None @@ -313,7 +292,6 @@ async def test_time_entity_error( ) ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED @@ -339,12 +317,10 @@ async def test_time_entity_error( @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_create_alarm_clock_deprecation_issue( hass: HomeAssistant, - appliance: HomeAppliance, + issue_registry: ir.IssueRegistry, + client: MagicMock, 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 alarm clock time entity or the entity is used by the user.""" entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" @@ -386,9 +362,8 @@ async def test_create_alarm_clock_deprecation_issue( }, ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( TIME_DOMAIN, @@ -420,13 +395,11 @@ async def test_create_alarm_clock_deprecation_issue( @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_alarm_clock_deprecation_issue_fix( hass: HomeAssistant, - appliance: HomeAppliance, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, ) -> None: """Test we can fix the issues created when a alarm clock time entity is in an automation or in a script or when is used.""" entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" @@ -468,9 +441,8 @@ async def test_alarm_clock_deprecation_issue_fix( }, ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( TIME_DOMAIN, diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 4facd1695c5..0646b4dcfa6 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -10,6 +10,7 @@ from homeassistant import config, core as ha from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, ATTR_SAFE_MODE, + DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_CHECK_CONFIG, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, @@ -32,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import entity, entity_registry as er +from homeassistant.helpers import entity, entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import ( @@ -637,3 +638,125 @@ async def test_reload_all( assert len(core_config) == 1 assert len(themes) == 1 assert len(jinja) == 1 + + +@pytest.mark.parametrize( + "arch", + [ + "i386", + "armhf", + "armv7", + ], +) +async def test_deprecated_installation_issue_32bit_core( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + arch: str, +) -> None: + """Test deprecated installation issue.""" + with ( + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant._is_32_bit", + return_value=True, + ), + ): + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_method_architecture" + ) + assert issue.domain == HOMEASSISTANT_DOMAIN + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == { + "installation_type": "Core", + "arch": arch, + } + + +@pytest.mark.parametrize( + "arch", + [ + "aarch64", + "generic-x86-64", + ], +) +async def test_deprecated_installation_issue_64bit_core( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + arch: str, +) -> None: + """Test deprecated installation issue.""" + with ( + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant._is_32_bit", + return_value=False, + ), + ): + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, "deprecated_method") + assert issue.domain == HOMEASSISTANT_DOMAIN + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == { + "installation_type": "Core", + "arch": arch, + } + + +@pytest.mark.parametrize( + "arch", + [ + "i386", + "armv7", + "armhf", + ], +) +async def test_deprecated_installation_issue_32bit( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + arch: str, +) -> None: + """Test deprecated installation issue.""" + with ( + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Container", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant._is_32_bit", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant._get_arch", + return_value=arch, + ), + ): + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, "deprecated_container") + assert issue.domain == HOMEASSISTANT_DOMAIN + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == {"arch": arch} diff --git a/tests/components/homeassistant/test_repairs.py b/tests/components/homeassistant/test_repairs.py index f84b29d8d2d..d9329744694 100644 --- a/tests/components/homeassistant/test_repairs.py +++ b/tests/components/homeassistant/test_repairs.py @@ -2,6 +2,7 @@ from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -10,13 +11,13 @@ from tests.components.repairs import ( process_repair_fix_flow, start_repair_fix_flow, ) -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import ClientSessionGenerator async def test_integration_not_found_confirm_step( hass: HomeAssistant, hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, ) -> None: """Test the integration_not_found issue confirm step.""" assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) @@ -33,17 +34,11 @@ async def test_integration_not_found_confirm_step( issue_id = "integration_not_found.test1" await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) http_client = await hass_client() - # Assert the issue is present - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) == 1 - issue = msg["result"]["issues"][0] - assert issue["issue_id"] == issue_id - assert issue["translation_placeholders"] == {"domain": "test1"} + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + assert issue is not None + assert issue.translation_placeholders == {"domain": "test1"} data = await start_repair_fix_flow(http_client, HOMEASSISTANT_DOMAIN, issue_id) @@ -68,16 +63,13 @@ async def test_integration_not_found_confirm_step( assert hass.config_entries.async_get_entry(entry2.entry_id) is None # Assert the issue is resolved - await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) == 0 + assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) async def test_integration_not_found_ignore_step( hass: HomeAssistant, hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, ) -> None: """Test the integration_not_found issue ignore step.""" assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) @@ -92,17 +84,11 @@ async def test_integration_not_found_ignore_step( issue_id = "integration_not_found.test1" await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) http_client = await hass_client() - # Assert the issue is present - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) == 1 - issue = msg["result"]["issues"][0] - assert issue["issue_id"] == issue_id - assert issue["translation_placeholders"] == {"domain": "test1"} + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + assert issue is not None + assert issue.translation_placeholders == {"domain": "test1"} data = await start_repair_fix_flow(http_client, HOMEASSISTANT_DOMAIN, issue_id) @@ -128,8 +114,6 @@ async def test_integration_not_found_ignore_step( assert hass.config_entries.async_get_entry(entry1.entry_id) # Assert the issue is resolved - await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) == 1 - assert msg["result"]["issues"][0].get("dismissed_version") is not None + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + assert issue is not None + assert issue.dismissed_version is not None diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index 293a9007175..5536db1eb5e 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -517,6 +517,51 @@ async def test_event_data_with_list( await hass.async_block_till_done() assert len(service_calls) == 1 + # don't match if property doesn't exist at all + hass.bus.async_fire("test_event", {"other_attr": [1, 2]}) + await hass.async_block_till_done() + assert len(service_calls) == 1 + + +async def test_event_data_with_list_nested( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test the (non)firing of event when the data schema has nested lists.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "event", + "event_type": "test_event", + "event_data": {"service_data": {"some_attr": [1, 2]}}, + "context": {}, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.bus.async_fire("test_event", {"service_data": {"some_attr": [1, 2]}}) + await hass.async_block_till_done() + assert len(service_calls) == 1 + + # don't match a single value + hass.bus.async_fire("test_event", {"service_data": {"some_attr": 1}}) + await hass.async_block_till_done() + assert len(service_calls) == 1 + + # don't match a containing list + hass.bus.async_fire("test_event", {"service_data": {"some_attr": [1, 2, 3]}}) + await hass.async_block_till_done() + assert len(service_calls) == 1 + + # don't match if property doesn't exist at all + hass.bus.async_fire("test_event", {"service_data": {"other_attr": [1, 2]}}) + await hass.async_block_till_done() + assert len(service_calls) == 1 + @pytest.mark.parametrize( "event_type", ["state_reported", ["test_event", "state_reported"]] diff --git a/tests/components/homeassistant_green/test_hardware.py b/tests/components/homeassistant_green/test_hardware.py index ab91514b297..4ede532d326 100644 --- a/tests/components/homeassistant_green/test_hardware.py +++ b/tests/components/homeassistant_green/test_hardware.py @@ -58,7 +58,7 @@ async def test_hardware_info( "config_entries": [config_entry.entry_id], "dongle": None, "name": "Home Assistant Green", - "url": "https://green.home-assistant.io/documentation/", + "url": "https://support.nabucasa.com/hc/en-us/categories/24638797677853-Home-Assistant-Green", } ] } diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index e59a1e7df06..2a594ebcdad 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -97,7 +97,7 @@ async def test_hardware_info( "description": "SkyConnect v1.0", }, "name": "Home Assistant SkyConnect", - "url": "https://skyconnect.home-assistant.io/documentation/", + "url": "https://support.nabucasa.com/hc/en-us/categories/24734620813469-Home-Assistant-Connect-ZBT-1", }, { "board": None, @@ -110,7 +110,7 @@ async def test_hardware_info( "description": "Home Assistant Connect ZBT-1", }, "name": "Home Assistant Connect ZBT-1", - "url": "https://skyconnect.home-assistant.io/documentation/", + "url": "https://support.nabucasa.com/hc/en-us/categories/24734620813469-Home-Assistant-Connect-ZBT-1", }, # Bad entry is skipped ] diff --git a/tests/components/homeassistant_yellow/test_hardware.py b/tests/components/homeassistant_yellow/test_hardware.py index 4fd2eddb704..8de03891ae1 100644 --- a/tests/components/homeassistant_yellow/test_hardware.py +++ b/tests/components/homeassistant_yellow/test_hardware.py @@ -59,7 +59,7 @@ async def test_hardware_info( "config_entries": [config_entry.entry_id], "dongle": None, "name": "Home Assistant Yellow", - "url": "https://yellow.home-assistant.io/documentation/", + "url": "https://support.nabucasa.com/hc/en-us/categories/24734575925149-Home-Assistant-Yellow", } ] } diff --git a/tests/components/homee/fixtures/events.json b/tests/components/homee/fixtures/events.json new file mode 100644 index 00000000000..351d35ec497 --- /dev/null +++ b/tests/components/homee/fixtures/events.json @@ -0,0 +1,46 @@ +{ + "id": 1, + "name": "Remote Control", + "profile": 41, + "image": "default", + "favorite": 0, + "order": 29, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1715356788, + "added": 1615396304, + "history": 1, + "cube_type": 14, + "note": "", + "services": 0, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 9, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 300, + "state": 1, + "last_changed": 1713470190, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [145] + } + } + ] +} diff --git a/tests/components/homee/fixtures/fan.json b/tests/components/homee/fixtures/fan.json new file mode 100644 index 00000000000..9a6cd028dc1 --- /dev/null +++ b/tests/components/homee/fixtures/fan.json @@ -0,0 +1,73 @@ +{ + "id": 77, + "name": "Test Fan", + "profile": 3019, + "image": "default", + "favorite": 0, + "order": 76, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736106044, + "added": 1723550156, + "history": 1, + "cube_type": 3, + "note": "", + "services": 1, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 77, + "instance": 0, + "minimum": 0, + "maximum": 8, + "current_value": 3.0, + "target_value": 3.0, + "last_value": 6.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 99, + "state": 5, + "last_changed": 1729920212, + "changed_by": 1, + "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": 77, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 100, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/fixtures/homee.json b/tests/components/homee/fixtures/homee.json new file mode 100644 index 00000000000..763e594c2fa --- /dev/null +++ b/tests/components/homee/fixtures/homee.json @@ -0,0 +1,135 @@ +{ + "id": -1, + "name": "homee", + "profile": 1, + "image": "default", + "favorite": 0, + "order": 0, + "protocol": 0, + "routing": 0, + "state": 1, + "state_changed": 16, + "added": 16, + "history": 1, + "cube_type": 0, + "note": "", + "services": 0, + "phonetic_name": "", + "owner": 0, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": -1, + "instance": 0, + "minimum": 0, + "maximum": 200, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 205, + "state": 1, + "last_changed": 1735815716, + "changed_by": 2, + "changed_by_id": 4, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 18, + "node_id": -1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 15.0, + "target_value": 15.0, + "last_value": 15.0, + "unit": "%", + "step_value": 0.1, + "editable": 0, + "type": 311, + "state": 1, + "last_changed": 1739390161, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + }, + { + "id": 19, + "node_id": -1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 5.0, + "target_value": 5.0, + "last_value": 10.0, + "unit": "%", + "step_value": 0.1, + "editable": 0, + "type": 312, + "state": 1, + "last_changed": 1739390161, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + }, + { + "id": 20, + "node_id": -1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 10.0, + "target_value": 10.0, + "last_value": 10.0, + "unit": "%", + "step_value": 0.1, + "editable": 0, + "type": 313, + "state": 1, + "last_changed": 1739390161, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_alarm_control_panel.ambr b/tests/components/homee/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..8095831965a --- /dev/null +++ b/tests/components/homee/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_alarm_control_panel_snapshot[alarm_control_panel.testhomee_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': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.testhomee_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': 'Status', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'homee_mode', + 'unique_id': '00055511EECC--1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel_snapshot[alarm_control_panel.testhomee_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': 'user - 4', + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'TestHomee Status', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.testhomee_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'armed_home', + }) +# --- diff --git a/tests/components/homee/snapshots/test_binary_sensor.ambr b/tests/components/homee/snapshots/test_binary_sensor.ambr index 4926c048f5b..0e9f02edf6c 100644 --- a/tests/components/homee/snapshots/test_binary_sensor.ambr +++ b/tests/components/homee/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', 'unique_id': '00055511EECC-1-1', @@ -75,6 +76,7 @@ 'original_name': 'Blackout', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'blackout_alarm', 'unique_id': '00055511EECC-1-2', @@ -123,6 +125,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'carbon_dioxide', 'unique_id': '00055511EECC-1-4', @@ -171,6 +174,7 @@ 'original_name': 'Carbon monoxide', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'carbon_monoxide', 'unique_id': '00055511EECC-1-3', @@ -219,6 +223,7 @@ 'original_name': 'Flood', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flood', 'unique_id': '00055511EECC-1-5', @@ -267,6 +272,7 @@ 'original_name': 'High temperature', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'high_temperature', 'unique_id': '00055511EECC-1-6', @@ -315,6 +321,7 @@ 'original_name': 'Leak', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leak_alarm', 'unique_id': '00055511EECC-1-7', @@ -363,6 +370,7 @@ 'original_name': 'Load', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'load_alarm', 'unique_id': '00055511EECC-1-8', @@ -410,6 +418,7 @@ 'original_name': 'Lock', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock', 'unique_id': '00055511EECC-1-9', @@ -458,6 +467,7 @@ 'original_name': 'Low temperature', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'low_temperature', 'unique_id': '00055511EECC-1-10', @@ -506,6 +516,7 @@ 'original_name': 'Malfunction', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'malfunction', 'unique_id': '00055511EECC-1-11', @@ -554,6 +565,7 @@ 'original_name': 'Maximum level', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'maximum', 'unique_id': '00055511EECC-1-12', @@ -602,6 +614,7 @@ 'original_name': 'Minimum level', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'minimum', 'unique_id': '00055511EECC-1-13', @@ -650,6 +663,7 @@ 'original_name': 'Motion', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion', 'unique_id': '00055511EECC-1-14', @@ -698,6 +712,7 @@ 'original_name': 'Motor blocked', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motor_blocked', 'unique_id': '00055511EECC-1-15', @@ -746,6 +761,7 @@ 'original_name': 'Opening', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'opening', 'unique_id': '00055511EECC-1-17', @@ -794,6 +810,7 @@ 'original_name': 'Overcurrent', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'overcurrent', 'unique_id': '00055511EECC-1-18', @@ -842,6 +859,7 @@ 'original_name': 'Overload', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'overload', 'unique_id': '00055511EECC-1-19', @@ -890,6 +908,7 @@ 'original_name': 'Plug', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plug', 'unique_id': '00055511EECC-1-16', @@ -938,6 +957,7 @@ 'original_name': 'Power', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', 'unique_id': '00055511EECC-1-21', @@ -986,6 +1006,7 @@ 'original_name': 'Presence', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'presence', 'unique_id': '00055511EECC-1-20', @@ -1034,6 +1055,7 @@ 'original_name': 'Rain', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rain', 'unique_id': '00055511EECC-1-22', @@ -1082,6 +1104,7 @@ 'original_name': 'Replace filter', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'replace_filter', 'unique_id': '00055511EECC-1-23', @@ -1130,6 +1153,7 @@ 'original_name': 'Smoke', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smoke', 'unique_id': '00055511EECC-1-24', @@ -1178,6 +1202,7 @@ 'original_name': 'Storage', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storage', 'unique_id': '00055511EECC-1-25', @@ -1226,6 +1251,7 @@ 'original_name': 'Surge', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'surge', 'unique_id': '00055511EECC-1-26', @@ -1274,6 +1300,7 @@ 'original_name': 'Tamper', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tamper', 'unique_id': '00055511EECC-1-27', @@ -1322,6 +1349,7 @@ 'original_name': 'Voltage drop', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_drop', 'unique_id': '00055511EECC-1-28', @@ -1370,6 +1398,7 @@ 'original_name': 'Water', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water', 'unique_id': '00055511EECC-1-29', diff --git a/tests/components/homee/snapshots/test_button.ambr b/tests/components/homee/snapshots/test_button.ambr index be2bbae539b..eea7e8ffd06 100644 --- a/tests/components/homee/snapshots/test_button.ambr +++ b/tests/components/homee/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00055511EECC-1-4', @@ -74,6 +75,7 @@ 'original_name': 'Automatic mode', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'automatic_mode', 'unique_id': '00055511EECC-1-1', @@ -121,6 +123,7 @@ 'original_name': 'Briefly open', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'briefly_open', 'unique_id': '00055511EECC-1-2', @@ -168,6 +171,7 @@ 'original_name': 'Identification mode', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'identification_mode', 'unique_id': '00055511EECC-1-3', @@ -216,6 +220,7 @@ 'original_name': 'Impulse 1', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'impulse_instance', 'unique_id': '00055511EECC-1-5', @@ -263,6 +268,7 @@ 'original_name': 'Impulse 2', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'impulse_instance', 'unique_id': '00055511EECC-1-6', @@ -310,6 +316,7 @@ 'original_name': 'Light', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': '00055511EECC-1-7', @@ -357,6 +364,7 @@ 'original_name': 'Open partially', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'open_partial', 'unique_id': '00055511EECC-1-8', @@ -404,6 +412,7 @@ 'original_name': 'Open permanently', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'permanently_open', 'unique_id': '00055511EECC-1-9', @@ -451,6 +460,7 @@ 'original_name': 'Reset meter 1', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_meter_instance', 'unique_id': '00055511EECC-1-10', @@ -498,6 +508,7 @@ 'original_name': 'Reset meter 2', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_meter_instance', 'unique_id': '00055511EECC-1-11', @@ -545,6 +556,7 @@ 'original_name': 'Ventilate', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ventilate', 'unique_id': '00055511EECC-1-12', diff --git a/tests/components/homee/snapshots/test_climate.ambr b/tests/components/homee/snapshots/test_climate.ambr index b79538ddcf0..2c94c5ef8e0 100644 --- a/tests/components/homee/snapshots/test_climate.ambr +++ b/tests/components/homee/snapshots/test_climate.ambr @@ -34,6 +34,7 @@ 'original_name': None, 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'homee', 'unique_id': '00055511EECC-1-1', @@ -98,6 +99,7 @@ 'original_name': None, 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'homee', 'unique_id': '00055511EECC-2-1', @@ -163,6 +165,7 @@ 'original_name': None, 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'homee', 'unique_id': '00055511EECC-3-1', @@ -235,6 +238,7 @@ 'original_name': None, 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'homee', 'unique_id': '00055511EECC-4-1', diff --git a/tests/components/homee/snapshots/test_event.ambr b/tests/components/homee/snapshots/test_event.ambr new file mode 100644 index 00000000000..b3f544bcc4e --- /dev/null +++ b/tests/components/homee/snapshots/test_event.ambr @@ -0,0 +1,76 @@ +# serializer version: 1 +# name: test_event_snapshot[event.remote_control_up_down_remote-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'released', + 'up', + 'down', + 'stop', + 'up_long', + 'down_long', + 'stop_long', + 'c_button', + 'b_button', + 'a_button', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.remote_control_up_down_remote', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Up/down remote', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'up_down_remote', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.remote_control_up_down_remote-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'released', + 'up', + 'down', + 'stop', + 'up_long', + 'down_long', + 'stop_long', + 'c_button', + 'b_button', + 'a_button', + ]), + 'friendly_name': 'Remote Control Up/down remote', + }), + 'context': , + 'entity_id': 'event.remote_control_up_down_remote', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/homee/snapshots/test_fan.ambr b/tests/components/homee/snapshots/test_fan.ambr new file mode 100644 index 00000000000..b6d77582aaf --- /dev/null +++ b/tests/components/homee/snapshots/test_fan.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_fan_snapshot[fan.test_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'manual', + 'auto', + 'summer', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.test_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': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-77', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_snapshot[fan.test_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fan', + 'percentage': 37, + 'percentage_step': 12.5, + 'preset_mode': 'manual', + 'preset_modes': list([ + 'manual', + 'auto', + 'summer', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.test_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/homee/snapshots/test_light.ambr b/tests/components/homee/snapshots/test_light.ambr index 3c766552467..2f22d95ae8d 100644 --- a/tests/components/homee/snapshots/test_light.ambr +++ b/tests/components/homee/snapshots/test_light.ambr @@ -35,6 +35,7 @@ 'original_name': None, 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00055511EECC-2-12', @@ -116,6 +117,7 @@ 'original_name': 'Light 1', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_instance', 'unique_id': '00055511EECC-1-1', @@ -198,6 +200,7 @@ 'original_name': 'Light 2', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_instance', 'unique_id': '00055511EECC-1-5', @@ -265,6 +268,7 @@ 'original_name': 'Light 3', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_instance', 'unique_id': '00055511EECC-1-9', @@ -322,6 +326,7 @@ 'original_name': 'Light 4', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_instance', 'unique_id': '00055511EECC-1-11', diff --git a/tests/components/homee/snapshots/test_lock.ambr b/tests/components/homee/snapshots/test_lock.ambr index d055039cca4..41563d6be41 100644 --- a/tests/components/homee/snapshots/test_lock.ambr +++ b/tests/components/homee/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00055511EECC-1-1', diff --git a/tests/components/homee/snapshots/test_number.ambr b/tests/components/homee/snapshots/test_number.ambr index 1fa2e0ef697..53569fe8734 100644 --- a/tests/components/homee/snapshots/test_number.ambr +++ b/tests/components/homee/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Down-movement duration', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'down_time', 'unique_id': '00055511EECC-1-3', @@ -90,6 +91,7 @@ 'original_name': 'Down position', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'down_position', 'unique_id': '00055511EECC-1-1', @@ -147,6 +149,7 @@ 'original_name': 'Down slat position', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'down_slat_position', 'unique_id': '00055511EECC-1-2', @@ -204,6 +207,7 @@ 'original_name': 'End position', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'endposition_configuration', 'unique_id': '00055511EECC-1-4', @@ -260,6 +264,7 @@ 'original_name': 'Maximum slat angle', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'slat_max_angle', 'unique_id': '00055511EECC-1-9', @@ -317,6 +322,7 @@ 'original_name': 'Minimum slat angle', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'slat_min_angle', 'unique_id': '00055511EECC-1-10', @@ -374,6 +380,7 @@ 'original_name': 'Motion alarm delay', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_alarm_cancelation_delay', 'unique_id': '00055511EECC-1-5', @@ -432,6 +439,7 @@ 'original_name': 'Polling interval', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'polling_interval', 'unique_id': '00055511EECC-1-7', @@ -490,6 +498,7 @@ 'original_name': 'Slat steps', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'slat_steps', 'unique_id': '00055511EECC-1-11', @@ -546,6 +555,7 @@ 'original_name': 'Slat turn duration', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'shutter_slat_time', 'unique_id': '00055511EECC-1-8', @@ -604,6 +614,7 @@ 'original_name': 'Temperature offset', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_offset', 'unique_id': '00055511EECC-1-12', @@ -661,6 +672,7 @@ 'original_name': 'Threshold for wind trigger', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_monitoring_state', 'unique_id': '00055511EECC-1-16', @@ -719,6 +731,7 @@ 'original_name': 'Up-movement duration', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'up_time', 'unique_id': '00055511EECC-1-13', @@ -777,6 +790,7 @@ 'original_name': 'Wake-up interval', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wake_up_interval', 'unique_id': '00055511EECC-1-14', @@ -835,6 +849,7 @@ 'original_name': 'Window open sensibility', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'open_window_detection_sensibility', 'unique_id': '00055511EECC-1-6', diff --git a/tests/components/homee/snapshots/test_select.ambr b/tests/components/homee/snapshots/test_select.ambr index 9fa831230c2..9f52f75e691 100644 --- a/tests/components/homee/snapshots/test_select.ambr +++ b/tests/components/homee/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Repeater mode', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'repeater_mode', 'unique_id': '00055511EECC-1-1', diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr index b35943630d5..618f2bcfdf6 100644 --- a/tests/components/homee/snapshots/test_sensor.ambr +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', 'unique_id': '00055511EECC-1-3', @@ -81,6 +82,7 @@ 'original_name': 'Battery', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_instance', 'unique_id': '00055511EECC-1-34', @@ -127,12 +129,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current 1', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_instance', 'unique_id': '00055511EECC-1-7', @@ -179,12 +185,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current 2', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_instance', 'unique_id': '00055511EECC-1-8', @@ -237,6 +247,7 @@ 'original_name': 'Dawn', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dawn', 'unique_id': '00055511EECC-1-10', @@ -283,12 +294,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Device temperature', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_temperature', 'unique_id': '00055511EECC-1-11', @@ -335,12 +350,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy 1', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_instance', 'unique_id': '00055511EECC-1-1', @@ -387,12 +406,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy 2', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_instance', 'unique_id': '00055511EECC-1-2', @@ -445,6 +468,7 @@ 'original_name': 'Exhaust motor speed', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'exhaust_motor_revs', 'unique_id': '00055511EECC-1-12', @@ -496,6 +520,7 @@ 'original_name': 'Humidity', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '00055511EECC-1-22', @@ -548,6 +573,7 @@ 'original_name': 'Illuminance', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brightness', 'unique_id': '00055511EECC-1-4', @@ -599,6 +625,7 @@ 'original_name': 'Illuminance 1', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brightness_instance', 'unique_id': '00055511EECC-1-5', @@ -651,6 +678,7 @@ 'original_name': 'Illuminance 2', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brightness_instance', 'unique_id': '00055511EECC-1-6', @@ -703,6 +731,7 @@ 'original_name': 'Indoor humidity', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_humidity', 'unique_id': '00055511EECC-1-13', @@ -749,12 +778,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Indoor temperature', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_temperature', 'unique_id': '00055511EECC-1-14', @@ -807,6 +840,7 @@ 'original_name': 'Intake motor speed', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'intake_motor_revs', 'unique_id': '00055511EECC-1-15', @@ -852,12 +886,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Level', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'level', 'unique_id': '00055511EECC-1-16', @@ -910,6 +948,7 @@ 'original_name': 'Link quality', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_quality', 'unique_id': '00055511EECC-1-17', @@ -975,6 +1014,7 @@ 'original_name': 'Node state', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'node_state', 'unique_id': '00055511EECC-1-state', @@ -1035,12 +1075,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Operating hours', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_hours', 'unique_id': '00055511EECC-1-18', @@ -1093,6 +1137,7 @@ 'original_name': 'Outdoor humidity', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outdoor_humidity', 'unique_id': '00055511EECC-1-19', @@ -1139,12 +1184,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outdoor temperature', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outdoor_temperature', 'unique_id': '00055511EECC-1-20', @@ -1197,6 +1246,7 @@ 'original_name': 'Position', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'position', 'unique_id': '00055511EECC-1-21', @@ -1254,6 +1304,7 @@ 'original_name': 'State', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'up_down', 'unique_id': '00055511EECC-1-28', @@ -1305,12 +1356,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '00055511EECC-1-23', @@ -1357,12 +1412,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total current', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_current', 'unique_id': '00055511EECC-1-25', @@ -1409,12 +1468,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': '00055511EECC-1-24', @@ -1461,12 +1524,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total power', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_power', 'unique_id': '00055511EECC-1-26', @@ -1513,12 +1580,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total voltage', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_voltage', 'unique_id': '00055511EECC-1-27', @@ -1571,6 +1642,7 @@ 'original_name': 'Ultraviolet', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv', 'unique_id': '00055511EECC-1-29', @@ -1591,57 +1663,6 @@ 'state': '6.0', }) # --- -# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-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.test_multisensor_valve_position', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Valve position', - 'platform': 'homee', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'valve_position', - 'unique_id': '00055511EECC-1-9', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test MultiSensor Valve position', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.test_multisensor_valve_position', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '70.0', - }) -# --- # name: test_sensor_snapshot[sensor.test_multisensor_voltage_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1666,12 +1687,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage 1', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_instance', 'unique_id': '00055511EECC-1-30', @@ -1718,12 +1743,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage 2', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_instance', 'unique_id': '00055511EECC-1-31', @@ -1770,6 +1799,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1779,6 +1811,7 @@ 'original_name': 'Wind speed', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed', 'unique_id': '00055511EECC-1-32', @@ -1835,6 +1868,7 @@ 'original_name': 'Window position', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'window_position', 'unique_id': '00055511EECC-1-33', diff --git a/tests/components/homee/snapshots/test_switch.ambr b/tests/components/homee/snapshots/test_switch.ambr index 43c1773cede..c8d68301884 100644 --- a/tests/components/homee/snapshots/test_switch.ambr +++ b/tests/components/homee/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Child lock', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'external_binary_input', 'unique_id': '00055511EECC-1-1', @@ -75,6 +76,7 @@ 'original_name': 'Manual operation', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'manual_operation', 'unique_id': '00055511EECC-1-2', @@ -123,6 +125,7 @@ 'original_name': 'Switch 1', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_off_instance', 'unique_id': '00055511EECC-1-3', @@ -171,6 +174,7 @@ 'original_name': 'Switch 2', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_off_instance', 'unique_id': '00055511EECC-1-4', @@ -219,6 +223,7 @@ 'original_name': 'Watchdog', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'watchdog', 'unique_id': '00055511EECC-1-5', diff --git a/tests/components/homee/snapshots/test_valve.ambr b/tests/components/homee/snapshots/test_valve.ambr index c76ecc6e780..bdf6d9f381c 100644 --- a/tests/components/homee/snapshots/test_valve.ambr +++ b/tests/components/homee/snapshots/test_valve.ambr @@ -27,6 +27,7 @@ 'original_name': 'Valve position', 'platform': 'homee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'valve_position', 'unique_id': '00055511EECC-1-1', diff --git a/tests/components/homee/test_alarm_control_panel.py b/tests/components/homee/test_alarm_control_panel.py new file mode 100644 index 00000000000..dafe74660ac --- /dev/null +++ b/tests/components/homee/test_alarm_control_panel.py @@ -0,0 +1,96 @@ +"""Test Homee alarm control panels.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, +) +from homeassistant.components.homee.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +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_alarm_control_panel( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Setups the integration for select tests.""" + mock_homee.nodes = [build_mock_node("homee.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("service", "state"), + [ + (SERVICE_ALARM_ARM_HOME, 0), + (SERVICE_ALARM_ARM_NIGHT, 1), + (SERVICE_ALARM_ARM_AWAY, 2), + (SERVICE_ALARM_ARM_VACATION, 3), + ], +) +async def test_alarm_control_panel_services( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + service: str, + state: int, +) -> None: + """Test alarm control panel services.""" + await setup_alarm_control_panel(hass, mock_homee, mock_config_entry) + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.testhomee_status"}, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(-1, 1, state) + + +async def test_alarm_control_panel_service_disarm_error( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that disarm service calls no action.""" + await setup_alarm_control_panel(hass, mock_homee, mock_config_entry) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.testhomee_status"}, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "disarm_not_supported" + + +async def test_alarm_control_panel_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the alarm-control_panel snapshots.""" + with patch( + "homeassistant.components.homee.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + await setup_alarm_control_panel(hass, mock_homee, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_event.py b/tests/components/homee/test_event.py new file mode 100644 index 00000000000..0ffa7cd8530 --- /dev/null +++ b/tests/components/homee/test_event.py @@ -0,0 +1,65 @@ +"""Test homee events.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import 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 test_event_fires( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the correct event fires when the attribute changes.""" + + EVENT_TYPES = [ + "released", + "up", + "down", + "stop", + "up_long", + "down_long", + "stop_long", + "c_button", + "b_button", + "a_button", + ] + mock_homee.nodes = [build_mock_node("events.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + # Simulate the event triggers. + attribute = mock_homee.nodes[0].attributes[0] + for i, event_type in enumerate(EVENT_TYPES): + attribute.current_value = i + attribute.add_on_changed_listener.call_args_list[1][0][0](attribute) + await hass.async_block_till_done() + + # Check if the event was fired + state = hass.states.get("event.remote_control_up_down_remote") + assert state.attributes[ATTR_EVENT_TYPE] == event_type + + +async def test_event_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the event entity snapshot.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.EVENT]): + mock_homee.nodes = [build_mock_node("events.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + 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_fan.py b/tests/components/homee/test_fan.py new file mode 100644 index 00000000000..55d019af746 --- /dev/null +++ b/tests/components/homee/test_fan.py @@ -0,0 +1,192 @@ +"""Test Homee fans.""" + +from unittest.mock import MagicMock, call, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.homee.const import ( + DOMAIN, + PRESET_AUTO, + PRESET_MANUAL, + PRESET_SUMMER, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + ("speed", "expected"), + [ + (0, 0), + (1, 12), + (2, 25), + (3, 37), + (4, 50), + (5, 62), + (6, 75), + (7, 87), + (8, 100), + ], +) +async def test_percentage( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + speed: int, + expected: int, +) -> None: + """Test percentage.""" + mock_homee.nodes = [build_mock_node("fan.json")] + mock_homee.nodes[0].attributes[0].current_value = speed + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("fan.test_fan").attributes["percentage"] == expected + + +@pytest.mark.parametrize( + ("mode_value", "expected"), + [ + (0, "manual"), + (1, "auto"), + (2, "summer"), + ], +) +async def test_preset_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + mode_value: int, + expected: str, +) -> None: + """Test preset mode.""" + mock_homee.nodes = [build_mock_node("fan.json")] + mock_homee.nodes[0].attributes[1].current_value = mode_value + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("fan.test_fan").attributes["preset_mode"] == expected + + +@pytest.mark.parametrize( + ("service", "options", "expected"), + [ + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 100}, (77, 1, 8)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 86}, (77, 1, 7)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 63}, (77, 1, 6)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 60}, (77, 1, 5)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 50}, (77, 1, 4)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 34}, (77, 1, 3)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 17}, (77, 1, 2)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 8}, (77, 1, 1)), + (SERVICE_TURN_ON, {}, (77, 1, 6)), + (SERVICE_TURN_OFF, {}, (77, 1, 0)), + (SERVICE_INCREASE_SPEED, {}, (77, 1, 4)), + (SERVICE_DECREASE_SPEED, {}, (77, 1, 2)), + (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 42}, (77, 1, 4)), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_MANUAL}, (77, 2, 0)), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_AUTO}, (77, 2, 1)), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_SUMMER}, (77, 2, 2)), + (SERVICE_TOGGLE, {}, (77, 1, 0)), + ], +) +async def test_fan_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + options: int | None, + expected: tuple[int, int, int], +) -> None: + """Test fan services.""" + mock_homee.nodes = [build_mock_node("fan.json")] + await setup_integration(hass, mock_config_entry) + + OPTIONS = {ATTR_ENTITY_ID: "fan.test_fan"} + OPTIONS.update(options) + + await hass.services.async_call( + FAN_DOMAIN, + service, + OPTIONS, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(*expected) + + +async def test_turn_on_preset_last_value_zero( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test turn on with preset last value == 0.""" + mock_homee.nodes = [build_mock_node("fan.json")] + mock_homee.nodes[0].attributes[0].last_value = 0 + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.test_fan", ATTR_PRESET_MODE: PRESET_MANUAL}, + blocking=True, + ) + + assert mock_homee.set_value.call_args_list == [ + call(77, 2, 0), + call(77, 1, 8), + ] + + +async def test_turn_on_invalid_preset( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test turn on with invalid preset.""" + mock_homee.nodes = [build_mock_node("fan.json")] + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.test_fan", ATTR_PRESET_MODE: PRESET_AUTO}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_preset_mode" + + +async def test_fan_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the fan snapshot.""" + mock_homee.nodes = [build_mock_node("fan.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.FAN]): + 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_sensor.py b/tests/components/homee/test_sensor.py index bbdad4c4469..14a9320ffa1 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -6,16 +6,19 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.homee.const import ( + DOMAIN, OPEN_CLOSE_MAP, OPEN_CLOSE_MAP_REVERSED, WINDOW_MAP, WINDOW_MAP_REVERSED, ) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from . import async_update_attribute_value, build_mock_node, setup_integration +from .conftest import HOMEE_ID from tests.common import MockConfigEntry, snapshot_platform @@ -25,15 +28,22 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None: """Make sure all entities are enabled.""" +async def setup_sensor( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Setups the integration for sensor tests.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + async def test_up_down_values( hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test values for up/down sensor.""" - mock_homee.nodes = [build_mock_node("sensors.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) + await setup_sensor(hass, mock_homee, mock_config_entry) assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] @@ -60,9 +70,7 @@ async def test_window_position( mock_config_entry: MockConfigEntry, ) -> None: """Test values for window handle position.""" - mock_homee.nodes = [build_mock_node("sensors.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) + await setup_sensor(hass, mock_homee, mock_config_entry) assert ( hass.states.get("sensor.test_multisensor_window_position").state @@ -87,6 +95,79 @@ async def test_window_position( ) +@pytest.mark.parametrize( + ("disabler", "expected_entity", "expected_issue"), + [ + (None, False, False), + (er.RegistryEntryDisabler.USER, True, True), + ], +) +async def test_sensor_deprecation( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + disabler: er.RegistryEntryDisabler, + expected_entity: bool, + expected_issue: bool, +) -> None: + """Test sensor deprecation issue.""" + entity_uid = f"{HOMEE_ID}-1-9" + entity_id = "test_multisensor_valve_position" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + entity_uid, + suggested_object_id=entity_id, + disabled_by=disabler, + ) + + with patch( + "homeassistant.components.homee.sensor.entity_used_in", return_value=True + ): + await setup_sensor(hass, mock_homee, mock_config_entry) + + assert (entity_registry.async_get(f"sensor.{entity_id}") is None) is expected_entity + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{entity_uid}", + ) + is None + ) is expected_issue + + +async def test_sensor_deprecation_unused_entity( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor deprecation issue.""" + entity_uid = f"{HOMEE_ID}-1-9" + entity_id = "test_multisensor_valve_position" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + entity_uid, + suggested_object_id=entity_id, + disabled_by=None, + ) + + await setup_sensor(hass, mock_homee, mock_config_entry) + + assert entity_registry.async_get(f"sensor.{entity_id}") is not None + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{entity_uid}", + ) + is None + ) + + async def test_sensor_snapshot( hass: HomeAssistant, mock_homee: MagicMock, diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 69c347ef55a..4d07757baf3 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -56,6 +56,9 @@ from homeassistant.components.homekit.const import ( PROP_MIN_VALUE, ) from homeassistant.components.homekit.type_thermostats import ( + FAN_STATE_ACTIVE, + FAN_STATE_IDLE, + FAN_STATE_INACTIVE, HC_HEAT_COOL_AUTO, HC_HEAT_COOL_COOL, HC_HEAT_COOL_HEAT, @@ -2493,6 +2496,98 @@ async def test_thermostat_with_supported_features_target_temp_but_fan_mode_set( assert not acc.fan_chars +async def test_thermostat_fan_state_with_preheating_and_defrosting( + hass: HomeAssistant, hk_driver +) -> None: + """Test thermostat fan state mappings for preheating and defrosting actions.""" + entity_id = "climate.test" + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE, + ATTR_FAN_MODES: [FAN_AUTO, FAN_LOW, FAN_HIGH], + ATTR_HVAC_ACTION: HVACAction.IDLE, + ATTR_FAN_MODE: FAN_AUTO, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + }, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Verify fan state characteristics are available + assert CHAR_CURRENT_FAN_STATE in acc.fan_chars + assert hasattr(acc, "char_current_fan_state") + + # Test PREHEATING action maps to FAN_STATE_IDLE + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE, + ATTR_FAN_MODES: [FAN_AUTO, FAN_LOW, FAN_HIGH], + ATTR_HVAC_ACTION: HVACAction.PREHEATING, + ATTR_FAN_MODE: FAN_AUTO, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + }, + ) + await hass.async_block_till_done() + assert acc.char_current_fan_state.value == FAN_STATE_IDLE + + # Test DEFROSTING action maps to FAN_STATE_IDLE + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE, + ATTR_FAN_MODES: [FAN_AUTO, FAN_LOW, FAN_HIGH], + ATTR_HVAC_ACTION: HVACAction.DEFROSTING, + ATTR_FAN_MODE: FAN_AUTO, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + }, + ) + await hass.async_block_till_done() + assert acc.char_current_fan_state.value == FAN_STATE_IDLE + + # Test other actions for comparison + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE, + ATTR_FAN_MODES: [FAN_AUTO, FAN_LOW, FAN_HIGH], + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_FAN_MODE: FAN_AUTO, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + }, + ) + await hass.async_block_till_done() + assert acc.char_current_fan_state.value == FAN_STATE_ACTIVE + + hass.states.async_set( + entity_id, + HVACMode.OFF, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE, + ATTR_FAN_MODES: [FAN_AUTO, FAN_LOW, FAN_HIGH], + ATTR_HVAC_ACTION: HVACAction.OFF, + ATTR_FAN_MODE: FAN_AUTO, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + }, + ) + await hass.async_block_till_done() + assert acc.char_current_fan_state.value == FAN_STATE_INACTIVE + + async def test_thermostat_handles_unknown_state(hass: HomeAssistant, hk_driver) -> None: """Test a thermostat can handle unknown state.""" entity_id = "climate.test" diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 324040f850f..4540cfd239a 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -66,6 +66,7 @@ 'original_name': 'Airversa AP2 1808 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -112,6 +113,7 @@ 'original_name': 'Airversa AP2 1808 AirPurifier', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832', @@ -165,6 +167,7 @@ 'original_name': 'Airversa AP2 1808 Air Purifier Mode', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_purifier_state_target', 'unique_id': '00:00:00:00:00:00_1_32832_32837', @@ -216,6 +219,7 @@ 'original_name': 'Airversa AP2 1808 Air Purifier Status', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_purifier_state_current', 'unique_id': '00:00:00:00:00:00_1_32832_32836', @@ -265,6 +269,7 @@ 'original_name': 'Airversa AP2 1808 Air Quality', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2576_2579', @@ -310,6 +315,7 @@ 'original_name': 'Airversa AP2 1808 Filter lifetime', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32896_32900', @@ -355,6 +361,7 @@ 'original_name': 'Airversa AP2 1808 PM2.5 Density', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2576_2580', @@ -408,6 +415,7 @@ 'original_name': 'Airversa AP2 1808 Thread Capabilities', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thread_node_capabilities', 'unique_id': '00:00:00:00:00:00_1_112_115', @@ -468,6 +476,7 @@ 'original_name': 'Airversa AP2 1808 Thread Status', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thread_status', 'unique_id': '00:00:00:00:00:00_1_112_117', @@ -519,6 +528,7 @@ 'original_name': 'Airversa AP2 1808 Lock Physical Controls', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock_physical_controls', 'unique_id': '00:00:00:00:00:00_1_32832_32839', @@ -560,6 +570,7 @@ 'original_name': 'Airversa AP2 1808 Mute', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mute', 'unique_id': '00:00:00:00:00:00_1_32832_32843', @@ -601,6 +612,7 @@ 'original_name': 'Airversa AP2 1808 Sleep Mode', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sleep_mode', 'unique_id': '00:00:00:00:00:00_1_32832_32842', @@ -685,6 +697,7 @@ 'original_name': 'eufy HomeBase2-0AAA Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -766,6 +779,7 @@ 'original_name': 'eufyCam2-0000 Motion Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_160', @@ -808,6 +822,7 @@ 'original_name': 'eufyCam2-0000 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_2', @@ -850,6 +865,7 @@ 'original_name': 'eufyCam2-0000', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4', @@ -894,6 +910,7 @@ 'original_name': 'eufyCam2-0000 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_101', @@ -939,6 +956,7 @@ 'original_name': 'eufyCam2-0000 Mute', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mute', 'unique_id': '00:00:00:00:00:00_4_80_83', @@ -1019,6 +1037,7 @@ 'original_name': 'eufyCam2-000A Motion Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_160', @@ -1061,6 +1080,7 @@ 'original_name': 'eufyCam2-000A Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2', @@ -1103,6 +1123,7 @@ 'original_name': 'eufyCam2-000A', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2', @@ -1147,6 +1168,7 @@ 'original_name': 'eufyCam2-000A Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_101', @@ -1192,6 +1214,7 @@ 'original_name': 'eufyCam2-000A Mute', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mute', 'unique_id': '00:00:00:00:00:00_2_80_83', @@ -1272,6 +1295,7 @@ 'original_name': 'eufyCam2-000A Motion Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_160', @@ -1314,6 +1338,7 @@ 'original_name': 'eufyCam2-000A Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_2', @@ -1356,6 +1381,7 @@ 'original_name': 'eufyCam2-000A', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3', @@ -1400,6 +1426,7 @@ 'original_name': 'eufyCam2-000A Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_101', @@ -1445,6 +1472,7 @@ 'original_name': 'eufyCam2-000A Mute', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mute', 'unique_id': '00:00:00:00:00:00_3_80_83', @@ -1529,6 +1557,7 @@ 'original_name': 'Aqara-Hub-E1-00A0 Security System', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -1574,6 +1603,7 @@ 'original_name': 'Aqara-Hub-E1-00A0 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_65537', @@ -1621,6 +1651,7 @@ 'original_name': 'Aqara-Hub-E1-00A0 Volume', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '00:00:00:00:00:00_1_17_1114116', @@ -1666,6 +1697,7 @@ 'original_name': 'Aqara-Hub-E1-00A0 Pairing Mode', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pairing_mode', 'unique_id': '00:00:00:00:00:00_1_17_1114117', @@ -1746,6 +1778,7 @@ 'original_name': 'Contact Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_33_4', @@ -1788,6 +1821,7 @@ 'original_name': 'Contact Sensor Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_33_1_65537', @@ -1832,6 +1866,7 @@ 'original_name': 'Contact Sensor Battery Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_33_5', @@ -1920,6 +1955,7 @@ 'original_name': 'Aqara Hub-1563 Security System', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_66304', @@ -1965,6 +2001,7 @@ 'original_name': 'Aqara Hub-1563 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -2016,6 +2053,7 @@ 'original_name': 'Aqara Hub-1563 Lightbulb-1563', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_65792', @@ -2078,6 +2116,7 @@ 'original_name': 'Aqara Hub-1563 Volume', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '00:00:00:00:00:00_1_65536_65541', @@ -2123,6 +2162,7 @@ 'original_name': 'Aqara Hub-1563 Pairing Mode', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pairing_mode', 'unique_id': '00:00:00:00:00:00_1_65536_65538', @@ -2207,6 +2247,7 @@ 'original_name': 'Programmable Switch Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_65537', @@ -2251,6 +2292,7 @@ 'original_name': 'Programmable Switch Battery Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_5', @@ -2339,6 +2381,7 @@ 'original_name': 'ArloBabyA0 Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_500', @@ -2381,6 +2424,7 @@ 'original_name': 'ArloBabyA0 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -2423,6 +2467,7 @@ 'original_name': 'ArloBabyA0', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1', @@ -2474,6 +2519,7 @@ 'original_name': 'ArloBabyA0 Nightlight', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1100', @@ -2533,6 +2579,7 @@ 'original_name': 'ArloBabyA0 Air Quality', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_800_802', @@ -2578,6 +2625,7 @@ 'original_name': 'ArloBabyA0 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_700', @@ -2625,6 +2673,7 @@ 'original_name': 'ArloBabyA0 Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_900', @@ -2665,12 +2714,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ArloBabyA0 Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1000', @@ -2715,6 +2768,7 @@ 'original_name': 'ArloBabyA0 Mute', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mute', 'unique_id': '00:00:00:00:00:00_1_300_302', @@ -2756,6 +2810,7 @@ 'original_name': 'ArloBabyA0 Mute', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mute', 'unique_id': '00:00:00:00:00:00_1_400_402', @@ -2840,6 +2895,7 @@ 'original_name': 'InWall Outlet-0394DE Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -2878,12 +2934,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Current', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13_18', @@ -2924,12 +2984,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Current', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25_30', @@ -2970,12 +3034,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Energy kWh', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13_20', @@ -3016,12 +3084,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Energy kWh', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25_32', @@ -3062,12 +3134,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Power', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13_19', @@ -3108,12 +3184,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Power', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25_31', @@ -3158,6 +3238,7 @@ 'original_name': 'InWall Outlet-0394DE Outlet A', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13', @@ -3200,6 +3281,7 @@ 'original_name': 'InWall Outlet-0394DE Outlet B', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25', @@ -3285,6 +3367,7 @@ 'original_name': 'Basement', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_56', @@ -3327,6 +3410,7 @@ 'original_name': 'Basement Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_4101', @@ -3365,12 +3449,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Basement Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_55', @@ -3454,6 +3542,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -3496,6 +3585,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -3538,6 +3628,7 @@ 'original_name': 'HomeW Clear Hold', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -3579,6 +3670,7 @@ 'original_name': 'HomeW Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -3632,6 +3724,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -3697,6 +3790,7 @@ 'original_name': 'HomeW Current Mode', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -3748,6 +3842,7 @@ 'original_name': 'HomeW Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -3795,6 +3890,7 @@ 'original_name': 'HomeW Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -3835,12 +3931,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'HomeW Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -3924,6 +4024,7 @@ 'original_name': 'Kitchen', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_56', @@ -3966,6 +4067,7 @@ 'original_name': 'Kitchen Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2053', @@ -4004,12 +4106,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Kitchen Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_55', @@ -4093,6 +4199,7 @@ 'original_name': 'Porch', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_56', @@ -4135,6 +4242,7 @@ 'original_name': 'Porch Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_3077', @@ -4173,12 +4281,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Porch Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_55', @@ -4266,6 +4378,7 @@ 'original_name': 'Basement Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608960_56', @@ -4308,6 +4421,7 @@ 'original_name': 'Basement Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608960_57', @@ -4350,6 +4464,7 @@ 'original_name': 'Basement Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608960_1_6', @@ -4394,6 +4509,7 @@ 'original_name': 'Basement Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608960_192', @@ -4435,12 +4551,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Basement Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608960_208', @@ -4524,6 +4644,7 @@ 'original_name': 'Basement Window 1 Contact', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360914_224', @@ -4566,6 +4687,7 @@ 'original_name': 'Basement Window 1 Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360914_56', @@ -4608,6 +4730,7 @@ 'original_name': 'Basement Window 1 Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360914_57', @@ -4650,6 +4773,7 @@ 'original_name': 'Basement Window 1 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360914_1_6', @@ -4694,6 +4818,7 @@ 'original_name': 'Basement Window 1 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360914_192', @@ -4778,6 +4903,7 @@ 'original_name': 'Deck Door Contact', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360921_224', @@ -4820,6 +4946,7 @@ 'original_name': 'Deck Door Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360921_56', @@ -4862,6 +4989,7 @@ 'original_name': 'Deck Door Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360921_57', @@ -4904,6 +5032,7 @@ 'original_name': 'Deck Door Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360921_1_6', @@ -4948,6 +5077,7 @@ 'original_name': 'Deck Door Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360921_192', @@ -5032,6 +5162,7 @@ 'original_name': 'Front Door Contact', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527970_224', @@ -5074,6 +5205,7 @@ 'original_name': 'Front Door Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527970_56', @@ -5116,6 +5248,7 @@ 'original_name': 'Front Door Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527970_57', @@ -5158,6 +5291,7 @@ 'original_name': 'Front Door Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527970_1_6', @@ -5202,6 +5336,7 @@ 'original_name': 'Front Door Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527970_192', @@ -5286,6 +5421,7 @@ 'original_name': 'Garage Door Contact', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527962_224', @@ -5328,6 +5464,7 @@ 'original_name': 'Garage Door Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527962_56', @@ -5370,6 +5507,7 @@ 'original_name': 'Garage Door Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527962_57', @@ -5412,6 +5550,7 @@ 'original_name': 'Garage Door Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527962_1_6', @@ -5456,6 +5595,7 @@ 'original_name': 'Garage Door Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298527962_192', @@ -5540,6 +5680,7 @@ 'original_name': 'Living Room Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016858_56', @@ -5582,6 +5723,7 @@ 'original_name': 'Living Room Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016858_57', @@ -5624,6 +5766,7 @@ 'original_name': 'Living Room Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016858_1_6', @@ -5668,6 +5811,7 @@ 'original_name': 'Living Room Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016858_192', @@ -5709,12 +5853,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Living Room Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016858_208', @@ -5798,6 +5946,7 @@ 'original_name': 'Living Room Window 1 Contact', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360712_224', @@ -5840,6 +5989,7 @@ 'original_name': 'Living Room Window 1 Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360712_56', @@ -5882,6 +6032,7 @@ 'original_name': 'Living Room Window 1 Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360712_57', @@ -5924,6 +6075,7 @@ 'original_name': 'Living Room Window 1 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360712_1_6', @@ -5968,6 +6120,7 @@ 'original_name': 'Living Room Window 1 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298360712_192', @@ -6052,6 +6205,7 @@ 'original_name': 'Loft window Contact', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298649931_224', @@ -6094,6 +6248,7 @@ 'original_name': 'Loft window Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298649931_56', @@ -6136,6 +6291,7 @@ 'original_name': 'Loft window Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298649931_57', @@ -6178,6 +6334,7 @@ 'original_name': 'Loft window Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298649931_1_6', @@ -6222,6 +6379,7 @@ 'original_name': 'Loft window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298649931_192', @@ -6306,6 +6464,7 @@ 'original_name': 'Master BR Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608971_56', @@ -6348,6 +6507,7 @@ 'original_name': 'Master BR Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608971_57', @@ -6390,6 +6550,7 @@ 'original_name': 'Master BR Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608971_1_6', @@ -6434,6 +6595,7 @@ 'original_name': 'Master BR Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608971_192', @@ -6475,12 +6637,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Master BR Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295608971_208', @@ -6564,6 +6730,7 @@ 'original_name': 'Master BR Window Contact', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298584118_224', @@ -6606,6 +6773,7 @@ 'original_name': 'Master BR Window Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298584118_56', @@ -6648,6 +6816,7 @@ 'original_name': 'Master BR Window Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298584118_57', @@ -6690,6 +6859,7 @@ 'original_name': 'Master BR Window Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298584118_1_6', @@ -6734,6 +6904,7 @@ 'original_name': 'Master BR Window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298584118_192', @@ -6818,6 +6989,7 @@ 'original_name': 'Thermostat Clear Hold', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -6859,6 +7031,7 @@ 'original_name': 'Thermostat Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -6914,6 +7087,7 @@ 'original_name': 'Thermostat', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -6981,6 +7155,7 @@ 'original_name': 'Thermostat Current Mode', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -7032,6 +7207,7 @@ 'original_name': 'Thermostat Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -7079,6 +7255,7 @@ 'original_name': 'Thermostat Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -7119,12 +7296,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Thermostat Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -7208,6 +7389,7 @@ 'original_name': 'Upstairs BR Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016969_56', @@ -7250,6 +7432,7 @@ 'original_name': 'Upstairs BR Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016969_57', @@ -7292,6 +7475,7 @@ 'original_name': 'Upstairs BR Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016969_1_6', @@ -7336,6 +7520,7 @@ 'original_name': 'Upstairs BR Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016969_192', @@ -7377,12 +7562,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Upstairs BR Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4295016969_208', @@ -7466,6 +7655,7 @@ 'original_name': 'Upstairs BR Window Contact', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298568508_224', @@ -7508,6 +7698,7 @@ 'original_name': 'Upstairs BR Window Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298568508_56', @@ -7550,6 +7741,7 @@ 'original_name': 'Upstairs BR Window Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298568508_57', @@ -7592,6 +7784,7 @@ 'original_name': 'Upstairs BR Window Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298568508_1_6', @@ -7636,6 +7829,7 @@ 'original_name': 'Upstairs BR Window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4298568508_192', @@ -7724,6 +7918,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -7766,6 +7961,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -7808,6 +8004,7 @@ 'original_name': 'HomeW Clear Hold', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -7849,6 +8046,7 @@ 'original_name': 'HomeW Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -7902,6 +8100,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -7967,6 +8166,7 @@ 'original_name': 'HomeW Current Mode', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -8018,6 +8218,7 @@ 'original_name': 'HomeW Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -8065,6 +8266,7 @@ 'original_name': 'HomeW Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -8105,12 +8307,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'HomeW Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -8198,6 +8404,7 @@ 'original_name': 'Basement', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_56', @@ -8240,6 +8447,7 @@ 'original_name': 'Basement Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_4101', @@ -8321,6 +8529,7 @@ 'original_name': 'HomeW Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -8374,6 +8583,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -8438,6 +8648,7 @@ 'original_name': 'HomeW Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -8485,6 +8696,7 @@ 'original_name': 'HomeW Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -8525,12 +8737,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'HomeW Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -8614,6 +8830,7 @@ 'original_name': 'Kitchen', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_56', @@ -8656,6 +8873,7 @@ 'original_name': 'Kitchen Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2053', @@ -8694,12 +8912,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Kitchen Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_55', @@ -8783,6 +9005,7 @@ 'original_name': 'Porch', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_56', @@ -8825,6 +9048,7 @@ 'original_name': 'Porch Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_3077', @@ -8863,12 +9087,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Porch Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_55', @@ -8956,6 +9184,7 @@ 'original_name': 'My ecobee Motion', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -8998,6 +9227,7 @@ 'original_name': 'My ecobee Occupancy', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -9040,6 +9270,7 @@ 'original_name': 'My ecobee Clear Hold', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -9081,6 +9312,7 @@ 'original_name': 'My ecobee Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -9138,6 +9370,7 @@ 'original_name': 'My ecobee', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -9208,6 +9441,7 @@ 'original_name': 'My ecobee Current Mode', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -9259,6 +9493,7 @@ 'original_name': 'My ecobee Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -9306,6 +9541,7 @@ 'original_name': 'My ecobee Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -9346,12 +9582,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'My ecobee Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -9439,6 +9679,7 @@ 'original_name': 'Master Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -9481,6 +9722,7 @@ 'original_name': 'Master Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -9523,6 +9765,7 @@ 'original_name': 'Master Fan Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -9567,6 +9810,7 @@ 'original_name': 'Master Fan Light Level', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_27', @@ -9607,12 +9851,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Master Fan Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_55', @@ -9657,6 +9905,7 @@ 'original_name': 'Master Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -9741,6 +9990,7 @@ 'original_name': 'Eve Degree AA11 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_3', @@ -9788,6 +10038,7 @@ 'original_name': 'Eve Degree AA11 Elevation', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'elevation', 'unique_id': '00:00:00:00:00:00_1_30_33', @@ -9838,6 +10089,7 @@ 'original_name': 'Eve Degree AA11 Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_22_25', @@ -9879,12 +10131,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Eve Degree AA11 Air Pressure', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_32', @@ -9931,6 +10187,7 @@ 'original_name': 'Eve Degree AA11 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17', @@ -9978,6 +10235,7 @@ 'original_name': 'Eve Degree AA11 Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_27', @@ -10018,12 +10276,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Eve Degree AA11 Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_22', @@ -10111,6 +10373,7 @@ 'original_name': 'Eve Energy 50FF Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_3', @@ -10149,12 +10412,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Eve Energy 50FF Amps', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_33', @@ -10195,12 +10462,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Eve Energy 50FF Energy kWh', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_35', @@ -10241,12 +10512,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Eve Energy 50FF Power', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_34', @@ -10287,12 +10562,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Eve Energy 50FF Volts', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_32', @@ -10337,6 +10616,7 @@ 'original_name': 'Eve Energy 50FF', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28', @@ -10379,6 +10659,7 @@ 'original_name': 'Eve Energy 50FF Lock Physical Controls', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock_physical_controls', 'unique_id': '00:00:00:00:00:00_1_28_36', @@ -10463,6 +10744,7 @@ 'original_name': 'HAA-C718B3 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -10505,6 +10787,7 @@ 'original_name': 'HAA-C718B3 Setup', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'setup', 'unique_id': '00:00:00:00:00:00_1_1010_1012', @@ -10546,6 +10829,7 @@ 'original_name': 'HAA-C718B3 Update', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1010_1011', @@ -10591,6 +10875,7 @@ 'original_name': 'HAA-C718B3', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -10681,6 +10966,7 @@ 'original_name': 'HAA-C718B3 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -10723,6 +11009,7 @@ 'original_name': 'HAA-C718B3', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -10807,6 +11094,7 @@ 'original_name': 'Family Room North Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_123016423_1_155', @@ -10849,6 +11137,7 @@ 'original_name': 'Family Room North', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_123016423_166', @@ -10894,6 +11183,7 @@ 'original_name': 'Family Room North Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_123016423_162', @@ -10978,6 +11268,7 @@ 'original_name': 'HASS Bridge S6 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -11059,6 +11350,7 @@ 'original_name': 'Kitchen Window Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_878448248_1_2', @@ -11101,6 +11393,7 @@ 'original_name': 'Kitchen Window', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_878448248_13', @@ -11146,6 +11439,7 @@ 'original_name': 'Kitchen Window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_878448248_9', @@ -11234,6 +11528,7 @@ 'original_name': 'Ceiling Fan Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_766313939_1_2', @@ -11279,6 +11574,7 @@ 'original_name': 'Ceiling Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_766313939_8', @@ -11365,6 +11661,7 @@ 'original_name': 'Home Assistant Bridge Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -11446,6 +11743,7 @@ 'original_name': 'Living Room Fan Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_1_2', @@ -11491,6 +11789,7 @@ 'original_name': 'Living Room Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_8', @@ -11582,6 +11881,7 @@ 'original_name': '89 Living Room Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_1_163', @@ -11634,6 +11934,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_169', @@ -11691,6 +11992,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', @@ -11745,6 +12047,7 @@ 'original_name': '89 Living Room Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1233851541_169_174', @@ -11792,6 +12095,7 @@ 'original_name': '89 Living Room Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_169_180', @@ -11832,12 +12136,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': '89 Living Room Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_169_172', @@ -11921,6 +12229,7 @@ 'original_name': 'HASS Bridge S6 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -12006,6 +12315,7 @@ 'original_name': 'HASS Bridge S6 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -12087,6 +12397,7 @@ 'original_name': 'Laundry Smoke ED78 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3982136094_1_597', @@ -12133,6 +12444,7 @@ 'original_name': 'Laundry Smoke ED78', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3982136094_608', @@ -12182,6 +12494,7 @@ 'original_name': 'Laundry Smoke ED78 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3982136094_604', @@ -12270,6 +12583,7 @@ 'original_name': 'Family Room North Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_123016423_1_155', @@ -12312,6 +12626,7 @@ 'original_name': 'Family Room North', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_123016423_166', @@ -12357,6 +12672,7 @@ 'original_name': 'Family Room North Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_123016423_162', @@ -12441,6 +12757,7 @@ 'original_name': 'HASS Bridge S6 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -12522,6 +12839,7 @@ 'original_name': 'Kitchen Window Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_878448248_1_2', @@ -12564,6 +12882,7 @@ 'original_name': 'Kitchen Window', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_878448248_13', @@ -12609,6 +12928,7 @@ 'original_name': 'Kitchen Window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_878448248_9', @@ -12697,6 +13017,7 @@ 'original_name': 'Ceiling Fan Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_766313939_1_2', @@ -12742,6 +13063,7 @@ 'original_name': 'Ceiling Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_766313939_8', @@ -12828,6 +13150,7 @@ 'original_name': 'Home Assistant Bridge Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -12909,6 +13232,7 @@ 'original_name': 'Living Room Fan Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_1_2', @@ -12954,6 +13278,7 @@ 'original_name': 'Living Room Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_8', @@ -13046,6 +13371,7 @@ 'original_name': 'Home Assistant Bridge Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -13127,6 +13453,7 @@ 'original_name': 'Living Room Fan Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_1_2', @@ -13172,6 +13499,7 @@ 'original_name': 'Living Room Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_8', @@ -13264,6 +13592,7 @@ 'original_name': '89 Living Room Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_1_163', @@ -13320,6 +13649,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_169', @@ -13382,6 +13712,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', @@ -13436,6 +13767,7 @@ 'original_name': '89 Living Room Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1233851541_169_174', @@ -13483,6 +13815,7 @@ 'original_name': '89 Living Room Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_169_180', @@ -13523,12 +13856,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': '89 Living Room Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_169_172', @@ -13612,6 +13949,7 @@ 'original_name': 'HASS Bridge S6 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -13697,6 +14035,7 @@ 'original_name': 'HASS Bridge S6 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -13778,6 +14117,7 @@ 'original_name': 'Humidifier 182A Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_293334836_1_2', @@ -13827,6 +14167,7 @@ 'original_name': 'Humidifier 182A', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_293334836_8', @@ -13881,6 +14222,7 @@ 'original_name': 'Humidifier 182A Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_293334836_8_9', @@ -13968,6 +14310,7 @@ 'original_name': 'HASS Bridge S6 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -14049,6 +14392,7 @@ 'original_name': 'Humidifier 182A Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_293334836_1_2', @@ -14098,6 +14442,7 @@ 'original_name': 'Humidifier 182A', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_293334836_8', @@ -14152,6 +14497,7 @@ 'original_name': 'Humidifier 182A Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_293334836_8_9', @@ -14239,6 +14585,7 @@ 'original_name': 'HASS Bridge S6 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -14320,6 +14667,7 @@ 'original_name': 'Laundry Smoke ED78 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3982136094_1_597', @@ -14371,6 +14719,7 @@ 'original_name': 'Laundry Smoke ED78', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3982136094_608', @@ -14430,6 +14779,7 @@ 'original_name': 'Laundry Smoke ED78 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3982136094_604', @@ -14518,6 +14868,7 @@ 'original_name': 'Air Conditioner Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -14576,6 +14927,7 @@ 'original_name': 'Air Conditioner SlaveID 1', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9', @@ -14633,12 +14985,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Air Conditioner Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9_11', @@ -14726,6 +15082,7 @@ 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276914_1_6', @@ -14776,6 +15133,7 @@ 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276914_2816', @@ -14871,6 +15229,7 @@ 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276939_1_6', @@ -14921,6 +15280,7 @@ 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276939_2816', @@ -15016,6 +15376,7 @@ 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403113447_1_6', @@ -15066,6 +15427,7 @@ 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403113447_2816', @@ -15161,6 +15523,7 @@ 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403233419_1_6', @@ -15211,6 +15574,7 @@ 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403233419_2816', @@ -15306,6 +15670,7 @@ 'original_name': 'Hue ambiance spot Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412411853_1_6', @@ -15356,6 +15721,7 @@ 'original_name': 'Hue ambiance spot', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412411853_2816', @@ -15461,6 +15827,7 @@ 'original_name': 'Hue ambiance spot Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412413293_1_6', @@ -15511,6 +15878,7 @@ 'original_name': 'Hue ambiance spot', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412413293_2816', @@ -15616,6 +15984,7 @@ 'original_name': 'Hue dimmer switch Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462389072572_1_22', @@ -15662,6 +16031,7 @@ 'original_name': 'Hue dimmer switch button 1', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410585088', @@ -15712,6 +16082,7 @@ 'original_name': 'Hue dimmer switch button 2', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410650624', @@ -15762,6 +16133,7 @@ 'original_name': 'Hue dimmer switch button 3', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410716160', @@ -15812,6 +16184,7 @@ 'original_name': 'Hue dimmer switch button 4', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410781696', @@ -15860,6 +16233,7 @@ 'original_name': 'Hue dimmer switch battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462389072572_644245094400', @@ -15944,6 +16318,7 @@ 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378982941_1_6', @@ -15990,6 +16365,7 @@ 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378982941_2816', @@ -16076,6 +16452,7 @@ 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378983942_1_6', @@ -16122,6 +16499,7 @@ 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378983942_2816', @@ -16208,6 +16586,7 @@ 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379122122_1_6', @@ -16254,6 +16633,7 @@ 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379122122_2816', @@ -16340,6 +16720,7 @@ 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379123707_1_6', @@ -16386,6 +16767,7 @@ 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379123707_2816', @@ -16472,6 +16854,7 @@ 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114163_1_6', @@ -16518,6 +16901,7 @@ 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114163_2816', @@ -16604,6 +16988,7 @@ 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114193_1_6', @@ -16650,6 +17035,7 @@ 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114193_2816', @@ -16736,6 +17122,7 @@ 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462385996792_1_6', @@ -16782,6 +17169,7 @@ 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462385996792_2816', @@ -16868,6 +17256,7 @@ 'original_name': 'Philips hue - 482544 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -16953,6 +17342,7 @@ 'original_name': 'Koogeek-LS1-20833F Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -17004,6 +17394,7 @@ 'original_name': 'Koogeek-LS1-20833F Light Strip', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_7', @@ -17104,6 +17495,7 @@ 'original_name': 'Koogeek-P1-A00AA0 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -17142,12 +17534,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Koogeek-P1-A00AA0 Power', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_21_22', @@ -17192,6 +17588,7 @@ 'original_name': 'Koogeek-P1-A00AA0 outlet', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_7', @@ -17277,6 +17674,7 @@ 'original_name': 'Koogeek-SW2-187A91 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -17315,12 +17713,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Koogeek-SW2-187A91 Power', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_14_18', @@ -17365,6 +17767,7 @@ 'original_name': 'Koogeek-SW2-187A91 Switch 1', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -17406,6 +17809,7 @@ 'original_name': 'Koogeek-SW2-187A91 Switch 2', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_11', @@ -17490,6 +17894,7 @@ 'original_name': 'Lennox Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -17541,6 +17946,7 @@ 'original_name': 'Lennox', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100', @@ -17602,6 +18008,7 @@ 'original_name': 'Lennox Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_100_105', @@ -17649,6 +18056,7 @@ 'original_name': 'Lennox Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100_107', @@ -17689,12 +18097,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Lennox Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100_103', @@ -17782,6 +18194,7 @@ 'original_name': 'LG webOS TV AF80 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -17834,6 +18247,7 @@ 'original_name': 'LG webOS TV AF80', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48', @@ -17887,6 +18301,7 @@ 'original_name': 'LG webOS TV AF80 Mute', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mute', 'unique_id': '00:00:00:00:00:00_1_80_82', @@ -17971,6 +18386,7 @@ 'original_name': 'Caséta® Wireless Fan Speed Control Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_21474836482_1_85899345921', @@ -18016,6 +18432,7 @@ 'original_name': 'Caséta® Wireless Fan Speed Control', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_21474836482_2', @@ -18102,6 +18519,7 @@ 'original_name': 'Smart Bridge 2 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_85899345921', @@ -18187,6 +18605,7 @@ 'original_name': 'MSS425F-15cc Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -18229,6 +18648,7 @@ 'original_name': 'MSS425F-15cc Outlet-1', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_12', @@ -18270,6 +18690,7 @@ 'original_name': 'MSS425F-15cc Outlet-2', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_15', @@ -18311,6 +18732,7 @@ 'original_name': 'MSS425F-15cc Outlet-3', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_18', @@ -18352,6 +18774,7 @@ 'original_name': 'MSS425F-15cc Outlet-4', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_21', @@ -18393,6 +18816,7 @@ 'original_name': 'MSS425F-15cc USB', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_24', @@ -18477,6 +18901,7 @@ 'original_name': 'MSS565-28da Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -18523,6 +18948,7 @@ 'original_name': 'MSS565-28da Dimmer Switch', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_12', @@ -18613,6 +19039,7 @@ 'original_name': 'Mysa-85dda9 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -18664,6 +19091,7 @@ 'original_name': 'Mysa-85dda9 Thermostat', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20', @@ -18722,6 +19150,7 @@ 'original_name': 'Mysa-85dda9 Display', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_40', @@ -18774,6 +19203,7 @@ 'original_name': 'Mysa-85dda9 Temperature Display Units', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_20_26', @@ -18821,6 +19251,7 @@ 'original_name': 'Mysa-85dda9 Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20_27', @@ -18861,12 +19292,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mysa-85dda9 Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20_25', @@ -18954,6 +19389,7 @@ 'original_name': 'Nanoleaf Strip 3B32 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -19005,6 +19441,7 @@ 'original_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_19', @@ -19081,6 +19518,7 @@ 'original_name': 'Nanoleaf Strip 3B32 Thread Capabilities', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thread_node_capabilities', 'unique_id': '00:00:00:00:00:00_1_31_115', @@ -19141,6 +19579,7 @@ 'original_name': 'Nanoleaf Strip 3B32 Thread Status', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thread_status', 'unique_id': '00:00:00:00:00:00_1_31_117', @@ -19235,6 +19674,7 @@ 'original_name': 'Netatmo-Doorbell-g738658 Motion Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_10', @@ -19277,6 +19717,7 @@ 'original_name': 'Netatmo-Doorbell-g738658 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -19319,6 +19760,7 @@ 'original_name': 'Netatmo-Doorbell-g738658', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1', @@ -19367,6 +19809,7 @@ 'original_name': 'Netatmo-Doorbell-g738658', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'doorbell', 'unique_id': '00:00:00:00:00:00_1_49', @@ -19415,6 +19858,7 @@ 'original_name': 'Netatmo-Doorbell-g738658 Mute', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mute', 'unique_id': '00:00:00:00:00:00_1_51_52', @@ -19456,6 +19900,7 @@ 'original_name': 'Netatmo-Doorbell-g738658 Mute', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mute', 'unique_id': '00:00:00:00:00:00_1_8_9', @@ -19540,6 +19985,7 @@ 'original_name': 'Smart CO Alarm Carbon Monoxide Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_22', @@ -19582,6 +20028,7 @@ 'original_name': 'Smart CO Alarm Low Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_36', @@ -19624,6 +20071,7 @@ 'original_name': 'Smart CO Alarm Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_7_3', @@ -19709,6 +20157,7 @@ 'original_name': 'Healthy Home Coach Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -19753,6 +20202,7 @@ 'original_name': 'Healthy Home Coach Air Quality', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_24_8', @@ -19798,6 +20248,7 @@ 'original_name': 'Healthy Home Coach Carbon Dioxide sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_10', @@ -19844,6 +20295,7 @@ 'original_name': 'Healthy Home Coach Humidity sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_14', @@ -19884,12 +20336,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Healthy Home Coach Noise', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20_21', @@ -19930,12 +20386,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Healthy Home Coach Temperature sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17', @@ -20023,6 +20483,7 @@ 'original_name': 'RainMachine-00ce4a Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -20065,6 +20526,7 @@ 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_512', @@ -20109,6 +20571,7 @@ 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_768', @@ -20153,6 +20616,7 @@ 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_1024', @@ -20197,6 +20661,7 @@ 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_1280', @@ -20241,6 +20706,7 @@ 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_1536', @@ -20285,6 +20751,7 @@ 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_1792', @@ -20329,6 +20796,7 @@ 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_2048', @@ -20373,6 +20841,7 @@ 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_2304', @@ -20460,6 +20929,7 @@ 'original_name': 'Master Bath South Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2', @@ -20502,6 +20972,7 @@ 'original_name': 'Master Bath South RYSE Shade', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_48', @@ -20547,6 +21018,7 @@ 'original_name': 'Master Bath South RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_64', @@ -20631,6 +21103,7 @@ 'original_name': 'RYSE SmartBridge Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -20712,6 +21185,7 @@ 'original_name': 'RYSE SmartShade Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_2', @@ -20754,6 +21228,7 @@ 'original_name': 'RYSE SmartShade RYSE Shade', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_48', @@ -20799,6 +21274,7 @@ 'original_name': 'RYSE SmartShade RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_64', @@ -20887,6 +21363,7 @@ 'original_name': 'BR Left Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_2', @@ -20929,6 +21406,7 @@ 'original_name': 'BR Left RYSE Shade', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_48', @@ -20974,6 +21452,7 @@ 'original_name': 'BR Left RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_64', @@ -21058,6 +21537,7 @@ 'original_name': 'LR Left Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2', @@ -21100,6 +21580,7 @@ 'original_name': 'LR Left RYSE Shade', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_48', @@ -21145,6 +21626,7 @@ 'original_name': 'LR Left RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_64', @@ -21229,6 +21711,7 @@ 'original_name': 'LR Right Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_2', @@ -21271,6 +21754,7 @@ 'original_name': 'LR Right RYSE Shade', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_48', @@ -21316,6 +21800,7 @@ 'original_name': 'LR Right RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_64', @@ -21400,6 +21885,7 @@ 'original_name': 'RYSE SmartBridge Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -21481,6 +21967,7 @@ 'original_name': 'RZSS Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_5_1_2', @@ -21523,6 +22010,7 @@ 'original_name': 'RZSS RYSE Shade', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_5_48', @@ -21568,6 +22056,7 @@ 'original_name': 'RZSS RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_5_64', @@ -21656,6 +22145,7 @@ 'original_name': 'SENSE Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_3', @@ -21698,6 +22188,7 @@ 'original_name': 'SENSE Lock Mechanism', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30', @@ -21783,6 +22274,7 @@ 'original_name': 'SIMPLEconnect Fan-06F674 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -21828,6 +22320,7 @@ 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -21880,6 +22373,7 @@ 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Light', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_29', @@ -21970,6 +22464,7 @@ 'original_name': 'VELUX Internal Cover Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -22012,6 +22507,7 @@ 'original_name': 'VELUX Internal Cover Venetian Blinds', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -22099,6 +22595,7 @@ 'original_name': 'U by Moen-015F44 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -22151,6 +22648,7 @@ 'original_name': 'U by Moen-015F44', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_11', @@ -22201,12 +22699,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'U by Moen-015F44 Current Temperature', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_11_13', @@ -22251,6 +22753,7 @@ 'original_name': 'U by Moen-015F44', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -22292,6 +22795,7 @@ 'original_name': 'U by Moen-015F44 Outlet 1', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_17', @@ -22334,6 +22838,7 @@ 'original_name': 'U by Moen-015F44 Outlet 2', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_22', @@ -22376,6 +22881,7 @@ 'original_name': 'U by Moen-015F44 Outlet 3', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_27', @@ -22418,6 +22924,7 @@ 'original_name': 'U by Moen-015F44 Outlet 4', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve', 'unique_id': '00:00:00:00:00:00_1_32', @@ -22503,6 +23010,7 @@ 'original_name': 'VELUX Sensor Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -22547,6 +23055,7 @@ 'original_name': 'VELUX Sensor Carbon Dioxide sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_14', @@ -22593,6 +23102,7 @@ 'original_name': 'VELUX Sensor Humidity sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_11', @@ -22633,12 +23143,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VELUX Sensor Temperature sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -22726,6 +23240,7 @@ 'original_name': 'VELUX Gateway Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -22807,6 +23322,7 @@ 'original_name': 'VELUX Sensor Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_7', @@ -22851,6 +23367,7 @@ 'original_name': 'VELUX Sensor Carbon Dioxide sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_14', @@ -22897,6 +23414,7 @@ 'original_name': 'VELUX Sensor Humidity sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_11', @@ -22937,12 +23455,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VELUX Sensor Temperature sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_8', @@ -23026,6 +23548,7 @@ 'original_name': 'VELUX Window Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_7', @@ -23068,6 +23591,7 @@ 'original_name': 'VELUX Window Roof Window', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_8', @@ -23155,6 +23679,7 @@ 'original_name': 'VELUX Window Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -23197,6 +23722,7 @@ 'original_name': 'VELUX Window Roof Window', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -23284,6 +23810,7 @@ 'original_name': 'VELUX External Cover Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -23326,6 +23853,7 @@ 'original_name': 'VELUX External Cover Awning Blinds', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -23412,6 +23940,7 @@ 'original_name': 'VOCOlinc-Flowerbud-0d324b Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -23461,6 +23990,7 @@ 'original_name': 'VOCOlinc-Flowerbud-0d324b', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30', @@ -23522,6 +24052,7 @@ 'original_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9', @@ -23594,6 +24125,7 @@ 'original_name': 'VOCOlinc-Flowerbud-0d324b Spray Quantity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'spray_quantity', 'unique_id': '00:00:00:00:00:00_1_30_38', @@ -23641,6 +24173,7 @@ 'original_name': 'VOCOlinc-Flowerbud-0d324b Current Humidity', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_33', @@ -23728,6 +24261,7 @@ 'original_name': 'VOCOlinc-VP3-123456 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -23766,12 +24300,16 @@ ]), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VOCOlinc-VP3-123456 Power', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48_97', @@ -23816,6 +24354,7 @@ 'original_name': 'VOCOlinc-VP3-123456 Outlet', 'platform': 'homekit_controller', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48', diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py index f79c875385d..e5408aa5e0f 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -1,6 +1,6 @@ """Test homekit_controller diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.homekit_controller.const import KNOWN_DEVICES diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index 8672dfedd13..bcadf407950 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -97,7 +97,8 @@ async def mock_hap_with_service_fixture( mock_hap = await default_mock_hap_factory.async_get_mock_hap() await hmip_async_setup(hass, dummy_config) await hass.async_block_till_done() - hass.data[HMIPC_DOMAIN] = {HAPID: mock_hap} + entry = hass.config_entries.async_entries(HMIPC_DOMAIN)[0] + entry.runtime_data = mock_hap return mock_hap diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py index 78c03c6847c..946ccc569a4 100644 --- a/tests/components/homematicip_cloud/helper.py +++ b/tests/components/homematicip_cloud/helper.py @@ -120,7 +120,7 @@ class HomeFactory: await self.hass.async_block_till_done() - hap = self.hass.data[HMIPC_DOMAIN][HAPID] + hap = self.hmip_config_entry.runtime_data mock_home.on_update(hap.async_update) mock_home.on_create(hap.async_create_entity) return hap diff --git a/tests/components/homematicip_cloud/test_alarm_control_panel.py b/tests/components/homematicip_cloud/test_alarm_control_panel.py index 853660ceac6..df83560b893 100644 --- a/tests/components/homematicip_cloud/test_alarm_control_panel.py +++ b/tests/components/homematicip_cloud/test_alarm_control_panel.py @@ -2,13 +2,8 @@ from homematicip.async_home import AsyncHome -from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, - AlarmControlPanelState, -) -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN +from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, get_and_check_entity_basics @@ -39,17 +34,6 @@ async def _async_manipulate_security_zones( await hass.async_block_till_done() -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, - ALARM_CONTROL_PANEL_DOMAIN, - {ALARM_CONTROL_PANEL_DOMAIN: {"platform": HMIPC_DOMAIN}}, - ) - - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_alarm_control_panel( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index 02e96b10fe8..4f6913cc8e8 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -2,8 +2,6 @@ from homematicip.base.enums import SmokeDetectorAlarmType, WindowState -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.homematicip_cloud.binary_sensor import ( ATTR_ACCELERATION_SENSOR_MODE, ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION, @@ -25,21 +23,10 @@ from homeassistant.components.homematicip_cloud.entity import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, - BINARY_SENSOR_DOMAIN, - {BINARY_SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}}, - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_home_cloud_connection_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index c39d4fa2d99..28d0fca0d80 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_PRESET_MODE, ATTR_PRESET_MODES, - DOMAIN as CLIMATE_DOMAIN, PRESET_AWAY, PRESET_BOOST, PRESET_ECO, @@ -26,7 +25,6 @@ from homeassistant.components.homematicip_cloud.climate import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.setup import async_setup_component from .helper import ( HAPID, @@ -36,14 +34,6 @@ from .helper import ( ) -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, CLIMATE_DOMAIN, {CLIMATE_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_heating_group_heat( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py index aa104da0546..b005090309b 100644 --- a/tests/components/homematicip_cloud/test_cover.py +++ b/tests/components/homematicip_cloud/test_cover.py @@ -5,25 +5,14 @@ from homematicip.base.enums import DoorCommand, DoorState from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, - DOMAIN as COVER_DOMAIN, CoverState, ) -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, COVER_DOMAIN, {COVER_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_cover_shutter( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index fd72f275489..abd0e18b368 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -4,18 +4,12 @@ from unittest.mock import patch from homematicip.base.enums import EventType -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.homematicip_cloud.hap import HomematicipHAP from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .helper import ( - HAPID, - HomeFactory, - async_manipulate_test_data, - get_and_check_entity_basics, -) +from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics from tests.common import MockConfigEntry @@ -115,7 +109,7 @@ async def test_hmip_add_device( assert len(device_registry.devices) == pre_device_count assert len(entity_registry.entities) == pre_entity_count - new_hap = hass.data[HMIPC_DOMAIN][HAPID] + new_hap = hmip_config_entry.runtime_data assert len(new_hap.hmip_device_by_entity_id) == pre_mapping_count diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index e34424d3439..c258c85ac93 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -119,14 +119,13 @@ async def test_hap_reset_unloads_entry_if_setup( ) -> None: """Test calling reset while the entry has been setup.""" mock_hap = await default_mock_hap_factory.async_get_mock_hap() - assert hass.data[HMIPC_DOMAIN][HAPID] == mock_hap config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN) assert len(config_entries) == 1 + assert config_entries[0].runtime_data == mock_hap # hap_reset is called during unload await hass.config_entries.async_unload(config_entries[0].entry_id) # entry is unloaded assert config_entries[0].state is ConfigEntryState.NOT_LOADED - assert hass.data[HMIPC_DOMAIN] == {} async def test_hap_create( @@ -232,3 +231,23 @@ async def test_auth_create_exception(hass: HomeAssistant, simple_mock_auth) -> N ), ): assert not await hmip_auth.get_auth(hass, HAPID, HAPPIN) + + +async def test_get_state_after_disconnect( + hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home +) -> None: + """Test get state after disconnect.""" + hass.config.components.add(HMIPC_DOMAIN) + hap = HomematicipHAP(hass, hmip_config_entry) + assert hap + + with patch.object(hap, "get_state") as mock_get_state: + assert not hap._ws_connection_closed.is_set() + + await hap.ws_connected_handler() + mock_get_state.assert_not_called() + + await hap.ws_disconnected_handler() + assert hap._ws_connection_closed.is_set() + await hap.ws_connected_handler() + mock_get_state.assert_called_once() diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py index f28b3870705..172119a556c 100644 --- a/tests/components/homematicip_cloud/test_init.py +++ b/tests/components/homematicip_cloud/test_init.py @@ -34,8 +34,6 @@ async def test_config_with_accesspoint_passed_to_config_entry( } # no config_entry exists assert len(hass.config_entries.async_entries(HMIPC_DOMAIN)) == 0 - # no acccesspoint exists - assert not hass.data.get(HMIPC_DOMAIN) with patch( "homeassistant.components.homematicip_cloud.hap.HomematicipHAP.async_connect", @@ -53,7 +51,7 @@ async def test_config_with_accesspoint_passed_to_config_entry( "name": "name", } # defined access_point created for config_entry - assert isinstance(hass.data[HMIPC_DOMAIN]["ABC123"], HomematicipHAP) + assert isinstance(config_entries[0].runtime_data, HomematicipHAP) async def test_config_already_registered_not_passed_to_config_entry( @@ -118,7 +116,7 @@ async def test_load_entry_fails_due_to_connection_error( ): assert await async_setup_component(hass, HMIPC_DOMAIN, {}) - assert hass.data[HMIPC_DOMAIN][hmip_config_entry.unique_id] + assert hmip_config_entry.runtime_data assert hmip_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -136,7 +134,7 @@ async def test_load_entry_fails_due_to_generic_exception( ): assert await async_setup_component(hass, HMIPC_DOMAIN, {}) - assert hass.data[HMIPC_DOMAIN][hmip_config_entry.unique_id] + assert hmip_config_entry.runtime_data assert hmip_config_entry.state is ConfigEntryState.SETUP_ERROR @@ -159,14 +157,12 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert mock_hap.return_value.mock_calls[0][0] == "async_setup" - assert hass.data[HMIPC_DOMAIN]["ABC123"] config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN) assert len(config_entries) == 1 + assert config_entries[0].runtime_data assert config_entries[0].state is ConfigEntryState.LOADED await hass.config_entries.async_unload(config_entries[0].entry_id) assert config_entries[0].state is ConfigEntryState.NOT_LOADED - # entry is unloaded - assert hass.data[HMIPC_DOMAIN] == {} async def test_hmip_dump_hap_config_services( diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index 48d9beccacc..b929bd337cc 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -2,7 +2,6 @@ from homematicip.base.enums import OpticalSignalBehaviour, RGBColorState -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -10,25 +9,15 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_HS_COLOR, ATTR_SUPPORTED_COLOR_MODES, - DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntityFeature, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_light( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py index dd581cce044..3805f0f08de 100644 --- a/tests/components/homematicip_cloud/test_lock.py +++ b/tests/components/homematicip_cloud/test_lock.py @@ -5,28 +5,14 @@ from unittest.mock import patch from homematicip.base.enums import LockState as HomematicLockState, MotorState import pytest -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN -from homeassistant.components.lock import ( - DOMAIN as LOCK_DOMAIN, - LockEntityFeature, - LockState, -) +from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, LOCK_DOMAIN, {LOCK_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_doorlockdrive( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index eebee050d51..3b5773cfa4d 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -2,7 +2,6 @@ from homematicip.base.enums import ValveState -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.homematicip_cloud.entity import ( ATTR_CONFIG_PENDING, ATTR_DEVICE_OVERHEATED, @@ -23,11 +22,7 @@ from homeassistant.components.homematicip_cloud.sensor import ( ATTR_WIND_DIRECTION, ATTR_WIND_DIRECTION_VARIATION, ) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, LIGHT_LUX, @@ -39,19 +34,10 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_accesspoint_status( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py index bd7952025bc..1a728bfecd4 100644 --- a/tests/components/homematicip_cloud/test_switch.py +++ b/tests/components/homematicip_cloud/test_switch.py @@ -1,25 +1,14 @@ """Tests for HomematicIP Cloud switch.""" -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.homematicip_cloud.entity import ( ATTR_GROUP_MEMBER_UNREACHABLE, ) -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_switch( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_weather.py b/tests/components/homematicip_cloud/test_weather.py index 44df907fcc5..ad97baf485b 100644 --- a/tests/components/homematicip_cloud/test_weather.py +++ b/tests/components/homematicip_cloud/test_weather.py @@ -1,28 +1,17 @@ """Tests for HomematicIP Cloud weather.""" -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.weather import ( ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, - DOMAIN as WEATHER_DOMAIN, ) from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, WEATHER_DOMAIN, {WEATHER_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_weather_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index 16cc62ad726..a07c0745c45 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -41,6 +41,7 @@ 'original_name': 'Identify', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_identify', diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 1c901bda6f6..3224a0cc63e 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -50,6 +50,7 @@ 'original_name': 'Status light brightness', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_light_brightness', 'unique_id': 'HWE-P1_5c2fafabcdef_status_light_brightness', @@ -144,6 +145,7 @@ 'original_name': 'Status light brightness', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_light_brightness', 'unique_id': 'HWE-P1_5c2fafabcdef_status_light_brightness', diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index f68b5a57d2e..9f95e140edc 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -66,6 +66,7 @@ 'original_name': 'Battery cycles', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cycles', 'unique_id': 'HWE-P1_5c2fafabcdef_cycles', @@ -147,12 +148,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', @@ -236,12 +241,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -325,12 +334,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -414,12 +427,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', @@ -512,6 +529,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -604,6 +622,7 @@ 'original_name': 'State of charge', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state_of_charge_pct', 'unique_id': 'HWE-P1_5c2fafabcdef_state_of_charge_pct', @@ -691,6 +710,7 @@ 'original_name': 'Uptime', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uptime', 'unique_id': 'HWE-P1_5c2fafabcdef_uptime', @@ -772,12 +792,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', @@ -867,6 +891,7 @@ 'original_name': 'Wi-Fi RSSI', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_rssi', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_rssi', @@ -953,6 +978,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -1033,12 +1059,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', @@ -1122,12 +1152,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', @@ -1211,12 +1245,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -1300,12 +1338,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -1389,12 +1431,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', @@ -1487,6 +1533,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -1576,6 +1623,7 @@ 'original_name': 'Power factor', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor', @@ -1659,12 +1707,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', @@ -1748,12 +1800,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', @@ -1841,6 +1897,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -1927,6 +1984,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', @@ -2009,12 +2067,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', @@ -2098,12 +2160,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l1_va', @@ -2187,12 +2253,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l2_va', @@ -2276,12 +2346,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l3_va', @@ -2365,12 +2439,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', @@ -2454,12 +2532,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', @@ -2543,12 +2625,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', @@ -2632,12 +2718,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', @@ -2721,12 +2811,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -2810,12 +2904,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -2899,12 +2997,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', @@ -2997,6 +3099,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -3086,6 +3189,7 @@ 'original_name': 'Power factor phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l1', @@ -3175,6 +3279,7 @@ 'original_name': 'Power factor phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l2', @@ -3264,6 +3369,7 @@ 'original_name': 'Power factor phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l3', @@ -3356,6 +3462,7 @@ 'original_name': 'Power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', @@ -3448,6 +3555,7 @@ 'original_name': 'Power phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', @@ -3540,6 +3648,7 @@ 'original_name': 'Power phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', @@ -3623,12 +3732,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', @@ -3712,12 +3825,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l1_var', @@ -3801,12 +3918,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l2_var', @@ -3890,12 +4011,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l3_var', @@ -3979,12 +4104,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', @@ -4068,12 +4197,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', @@ -4157,12 +4290,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', @@ -4250,6 +4387,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -4336,6 +4474,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', @@ -4416,12 +4555,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Average demand', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_average_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_average_w', @@ -4504,12 +4647,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', @@ -4593,12 +4740,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', @@ -4682,12 +4833,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', @@ -4775,6 +4930,7 @@ 'original_name': 'DSMR version', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsmr_version', 'unique_id': 'HWE-P1_5c2fafabcdef_smr_version', @@ -4855,12 +5011,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -4944,12 +5104,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t1_kwh', @@ -5033,12 +5197,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t2_kwh', @@ -5122,12 +5290,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t3_kwh', @@ -5211,12 +5383,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 4', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t4_kwh', @@ -5300,12 +5476,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -5389,12 +5569,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t1_kwh', @@ -5478,12 +5662,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t2_kwh', @@ -5567,12 +5755,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t3_kwh', @@ -5656,12 +5848,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 4', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t4_kwh', @@ -5745,12 +5941,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', @@ -5838,6 +6038,7 @@ 'original_name': 'Long power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'long_power_fail_count', 'unique_id': 'HWE-P1_5c2fafabcdef_long_power_fail_count', @@ -5916,12 +6117,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Peak demand current month', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monthly_power_peak_w', 'unique_id': 'HWE-P1_5c2fafabcdef_monthly_power_peak_w', @@ -6013,6 +6218,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -6100,6 +6306,7 @@ 'original_name': 'Power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'any_power_fail_count', 'unique_id': 'HWE-P1_5c2fafabcdef_any_power_fail_count', @@ -6189,6 +6396,7 @@ 'original_name': 'Power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', @@ -6281,6 +6489,7 @@ 'original_name': 'Power phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', @@ -6373,6 +6582,7 @@ 'original_name': 'Power phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', @@ -6460,6 +6670,7 @@ 'original_name': 'Smart meter identifier', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'unique_meter_id', 'unique_id': 'HWE-P1_5c2fafabcdef_unique_meter_id', @@ -6544,6 +6755,7 @@ 'original_name': 'Smart meter model', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_model', 'unique_id': 'HWE-P1_5c2fafabcdef_meter_model', @@ -6635,6 +6847,7 @@ 'original_name': 'Tariff', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_tariff', 'unique_id': 'HWE-P1_5c2fafabcdef_active_tariff', @@ -6722,12 +6935,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_liter_m3', 'unique_id': 'HWE-P1_5c2fafabcdef_total_liter_m3', @@ -6811,12 +7028,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', @@ -6900,12 +7121,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', @@ -6989,12 +7214,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', @@ -7082,6 +7311,7 @@ 'original_name': 'Voltage sags detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l1_count', @@ -7166,6 +7396,7 @@ 'original_name': 'Voltage sags detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l2_count', @@ -7250,6 +7481,7 @@ 'original_name': 'Voltage sags detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l3_count', @@ -7334,6 +7566,7 @@ 'original_name': 'Voltage swells detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l1_count', @@ -7418,6 +7651,7 @@ 'original_name': 'Voltage swells detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l2_count', @@ -7502,6 +7736,7 @@ 'original_name': 'Voltage swells detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l3_count', @@ -7588,6 +7823,7 @@ 'original_name': 'Water usage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', @@ -7674,6 +7910,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -7760,6 +7997,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', @@ -7838,12 +8076,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Gas', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_gas_meter_G001', @@ -7923,12 +8165,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_heat_meter_H001', @@ -8014,6 +8260,7 @@ 'original_name': None, 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_inlet_heat_meter_IH001', @@ -8092,12 +8339,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_warm_water_meter_WW001', @@ -8177,12 +8428,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_water_meter_W001', @@ -8264,12 +8519,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Average demand', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_average_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_average_w', @@ -8352,12 +8611,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', @@ -8441,12 +8704,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', @@ -8530,12 +8797,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', @@ -8623,6 +8894,7 @@ 'original_name': 'DSMR version', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsmr_version', 'unique_id': 'HWE-P1_5c2fafabcdef_smr_version', @@ -8703,12 +8975,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -8792,12 +9068,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t1_kwh', @@ -8881,12 +9161,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t2_kwh', @@ -8970,12 +9254,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t3_kwh', @@ -9059,12 +9347,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 4', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t4_kwh', @@ -9148,12 +9440,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -9237,12 +9533,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t1_kwh', @@ -9326,12 +9626,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t2_kwh', @@ -9415,12 +9719,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t3_kwh', @@ -9504,12 +9812,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 4', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t4_kwh', @@ -9593,12 +9905,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', @@ -9686,6 +10002,7 @@ 'original_name': 'Long power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'long_power_fail_count', 'unique_id': 'HWE-P1_5c2fafabcdef_long_power_fail_count', @@ -9764,12 +10081,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Peak demand current month', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monthly_power_peak_w', 'unique_id': 'HWE-P1_5c2fafabcdef_monthly_power_peak_w', @@ -9861,6 +10182,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -9948,6 +10270,7 @@ 'original_name': 'Power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'any_power_fail_count', 'unique_id': 'HWE-P1_5c2fafabcdef_any_power_fail_count', @@ -10037,6 +10360,7 @@ 'original_name': 'Power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', @@ -10129,6 +10453,7 @@ 'original_name': 'Power phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', @@ -10221,6 +10546,7 @@ 'original_name': 'Power phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', @@ -10308,6 +10634,7 @@ 'original_name': 'Smart meter identifier', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'unique_meter_id', 'unique_id': 'HWE-P1_5c2fafabcdef_unique_meter_id', @@ -10392,6 +10719,7 @@ 'original_name': 'Smart meter model', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_model', 'unique_id': 'HWE-P1_5c2fafabcdef_meter_model', @@ -10483,6 +10811,7 @@ 'original_name': 'Tariff', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_tariff', 'unique_id': 'HWE-P1_5c2fafabcdef_active_tariff', @@ -10570,12 +10899,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_liter_m3', 'unique_id': 'HWE-P1_5c2fafabcdef_total_liter_m3', @@ -10659,12 +10992,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', @@ -10748,12 +11085,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', @@ -10837,12 +11178,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', @@ -10930,6 +11275,7 @@ 'original_name': 'Voltage sags detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l1_count', @@ -11014,6 +11360,7 @@ 'original_name': 'Voltage sags detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l2_count', @@ -11098,6 +11445,7 @@ 'original_name': 'Voltage sags detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l3_count', @@ -11182,6 +11530,7 @@ 'original_name': 'Voltage swells detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l1_count', @@ -11266,6 +11615,7 @@ 'original_name': 'Voltage swells detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l2_count', @@ -11350,6 +11700,7 @@ 'original_name': 'Voltage swells detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l3_count', @@ -11436,6 +11787,7 @@ 'original_name': 'Water usage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', @@ -11522,6 +11874,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -11608,6 +11961,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', @@ -11686,12 +12040,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Gas', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', @@ -11771,12 +12129,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', @@ -11862,6 +12224,7 @@ 'original_name': None, 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', @@ -11940,12 +12303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', @@ -12025,12 +12392,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'homewizard_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', @@ -12112,12 +12483,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Average demand', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_average_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_average_w', @@ -12200,12 +12575,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', @@ -12289,12 +12668,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', @@ -12378,12 +12761,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', @@ -12467,12 +12854,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -12556,12 +12947,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t1_kwh', @@ -12645,12 +13040,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t2_kwh', @@ -12734,12 +13133,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t3_kwh', @@ -12823,12 +13226,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 4', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t4_kwh', @@ -12912,12 +13319,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -13001,12 +13412,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t1_kwh', @@ -13090,12 +13505,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t2_kwh', @@ -13179,12 +13598,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t3_kwh', @@ -13268,12 +13691,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 4', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t4_kwh', @@ -13357,12 +13784,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', @@ -13450,6 +13881,7 @@ 'original_name': 'Long power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'long_power_fail_count', 'unique_id': 'HWE-P1_5c2fafabcdef_long_power_fail_count', @@ -13539,6 +13971,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -13626,6 +14059,7 @@ 'original_name': 'Power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'any_power_fail_count', 'unique_id': 'HWE-P1_5c2fafabcdef_any_power_fail_count', @@ -13715,6 +14149,7 @@ 'original_name': 'Power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', @@ -13807,6 +14242,7 @@ 'original_name': 'Power phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', @@ -13899,6 +14335,7 @@ 'original_name': 'Power phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', @@ -13982,12 +14419,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_liter_m3', 'unique_id': 'HWE-P1_5c2fafabcdef_total_liter_m3', @@ -14071,12 +14512,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', @@ -14160,12 +14605,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', @@ -14249,12 +14698,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', @@ -14342,6 +14795,7 @@ 'original_name': 'Voltage sags detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l1_count', @@ -14426,6 +14880,7 @@ 'original_name': 'Voltage sags detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l2_count', @@ -14510,6 +14965,7 @@ 'original_name': 'Voltage sags detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l3_count', @@ -14594,6 +15050,7 @@ 'original_name': 'Voltage swells detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l1_count', @@ -14678,6 +15135,7 @@ 'original_name': 'Voltage swells detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l2_count', @@ -14762,6 +15220,7 @@ 'original_name': 'Voltage swells detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l3_count', @@ -14848,6 +15307,7 @@ 'original_name': 'Water usage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', @@ -14934,6 +15394,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -15020,6 +15481,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', @@ -15102,12 +15564,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -15191,12 +15657,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -15289,6 +15759,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -15381,6 +15852,7 @@ 'original_name': 'Power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', @@ -15468,6 +15940,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -15554,6 +16027,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', @@ -15636,12 +16110,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', @@ -15725,12 +16203,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', @@ -15814,12 +16296,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -15903,12 +16389,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -15992,12 +16482,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', @@ -16090,6 +16584,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -16179,6 +16674,7 @@ 'original_name': 'Power factor', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor', @@ -16271,6 +16767,7 @@ 'original_name': 'Power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', @@ -16354,12 +16851,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', @@ -16443,12 +16944,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', @@ -16536,6 +17041,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -16622,6 +17128,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', @@ -16704,12 +17211,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_liter_m3', 'unique_id': 'HWE-P1_5c2fafabcdef_total_liter_m3', @@ -16799,6 +17310,7 @@ 'original_name': 'Water usage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', @@ -16885,6 +17397,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -16971,6 +17484,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', @@ -17053,12 +17567,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', @@ -17142,12 +17660,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', @@ -17231,12 +17753,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -17320,12 +17846,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -17409,12 +17939,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', @@ -17507,6 +18041,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -17596,6 +18131,7 @@ 'original_name': 'Power factor', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor', @@ -17679,12 +18215,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', @@ -17768,12 +18308,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', @@ -17861,6 +18405,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -17947,6 +18492,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', @@ -18029,12 +18575,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', @@ -18118,12 +18668,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l1_va', @@ -18207,12 +18761,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l2_va', @@ -18296,12 +18854,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l3_va', @@ -18385,12 +18947,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', @@ -18474,12 +19040,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', @@ -18563,12 +19133,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', @@ -18652,12 +19226,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', @@ -18741,12 +19319,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', @@ -18830,12 +19412,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', @@ -18919,12 +19505,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', @@ -19017,6 +19607,7 @@ 'original_name': 'Power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', @@ -19106,6 +19697,7 @@ 'original_name': 'Power factor phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l1', @@ -19195,6 +19787,7 @@ 'original_name': 'Power factor phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l2', @@ -19284,6 +19877,7 @@ 'original_name': 'Power factor phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l3', @@ -19376,6 +19970,7 @@ 'original_name': 'Power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', @@ -19468,6 +20063,7 @@ 'original_name': 'Power phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', @@ -19560,6 +20156,7 @@ 'original_name': 'Power phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', @@ -19643,12 +20240,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', @@ -19732,12 +20333,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l1_var', @@ -19821,12 +20426,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l2_var', @@ -19910,12 +20519,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reactive power phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l3_var', @@ -19999,12 +20612,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', @@ -20088,12 +20705,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', @@ -20177,12 +20798,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', @@ -20270,6 +20895,7 @@ 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', @@ -20356,6 +20982,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index cd21cb92819..c4e67003b58 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -40,6 +40,7 @@ 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', @@ -124,6 +125,7 @@ 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', @@ -209,6 +211,7 @@ 'original_name': None, 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_power_on', @@ -293,6 +296,7 @@ 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', @@ -377,6 +381,7 @@ 'original_name': 'Switch lock', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'switch_lock', 'unique_id': 'HWE-P1_5c2fafabcdef_switch_lock', @@ -462,6 +467,7 @@ 'original_name': None, 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'HWE-P1_5c2fafabcdef_power_on', @@ -546,6 +552,7 @@ 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', @@ -630,6 +637,7 @@ 'original_name': 'Switch lock', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'switch_lock', 'unique_id': 'HWE-P1_5c2fafabcdef_switch_lock', @@ -714,6 +722,7 @@ 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', @@ -798,6 +807,7 @@ 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', @@ -882,6 +892,7 @@ 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', diff --git a/tests/components/honeywell/test_diagnostics.py b/tests/components/honeywell/test_diagnostics.py index 06c41d3d055..a857a7f633f 100644 --- a/tests/components/honeywell/test_diagnostics.py +++ b/tests/components/honeywell/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/honeywell/test_sensor.py b/tests/components/honeywell/test_sensor.py index ed46fd4cdd2..23df33703d2 100644 --- a/tests/components/honeywell/test_sensor.py +++ b/tests/components/honeywell/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -@pytest.mark.parametrize(("unit", "temp"), [("C", "5"), ("F", "-15")]) +@pytest.mark.parametrize(("unit", "temp"), [("C", 5), ("F", -15)]) async def test_outdoor_sensor( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -32,11 +32,11 @@ async def test_outdoor_sensor( assert temperature_state assert humidity_state - assert temperature_state.state == temp - assert humidity_state.state == "25" + assert float(temperature_state.state) == temp + assert float(humidity_state.state) == 25 -@pytest.mark.parametrize(("unit", "temp"), [("C", "5"), ("F", "-15")]) +@pytest.mark.parametrize(("unit", "temp"), [("C", 5), ("F", -15)]) async def test_indoor_sensor( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -62,5 +62,5 @@ async def test_indoor_sensor( assert temperature_state assert humidity_state - assert temperature_state.state == temp + assert float(temperature_state.state) == temp assert humidity_state.state == "25" diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 7fc6c5ae33f..9fb291c57b4 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -59,7 +59,7 @@ def create_mock_bridge(hass: HomeAssistant, api_version: int = 1) -> Mock: async def async_initialize_bridge(): if bridge.config_entry: - hass.data.setdefault(hue.DOMAIN, {})[bridge.config_entry.entry_id] = bridge + bridge.config_entry.runtime_data = bridge if bridge.api_version == 2: await async_setup_devices(bridge) return True @@ -73,7 +73,7 @@ def create_mock_bridge(hass: HomeAssistant, api_version: int = 1) -> Mock: async def async_reset(): if bridge.config_entry: - hass.data[hue.DOMAIN].pop(bridge.config_entry.entry_id) + delattr(bridge.config_entry, "runtime_data") return True bridge.async_reset = async_reset @@ -254,6 +254,8 @@ async def setup_bridge( with patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge): await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.LOADED + async def setup_platform( hass: HomeAssistant, @@ -271,7 +273,7 @@ async def setup_platform( api_version=mock_bridge.api_version, host=hostname ) mock_bridge.config_entry = config_entry - hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} + config_entry.runtime_data = {config_entry.entry_id: mock_bridge} # simulate a full setup by manually adding the bridge config entry await setup_bridge(hass, mock_bridge, config_entry) diff --git a/tests/components/hue/test_diagnostics.py b/tests/components/hue/test_diagnostics.py index 49681601ebf..a9171d2a12a 100644 --- a/tests/components/hue/test_diagnostics.py +++ b/tests/components/hue/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import Mock from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonArrayType from .conftest import setup_platform @@ -21,9 +22,13 @@ async def test_diagnostics_v1( async def test_diagnostics_v2( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_bridge_v2: Mock + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, ) -> None: """Test diagnostics v2.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) mock_bridge_v2.api.get_diagnostics.return_value = {"hello": "world"} await setup_platform(hass, mock_bridge_v2, []) config_entry = hass.config_entries.async_entries("hue")[0] diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 5ce0d78ead9..6b162a22165 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -42,7 +42,7 @@ async def test_setup_with_no_config(hass: HomeAssistant) -> None: assert len(hass.config_entries.flow.async_progress()) == 0 # No configs stored - assert hue.DOMAIN not in hass.data + assert not hass.config_entries.async_entries(hue.DOMAIN) async def test_unload_entry(hass: HomeAssistant, mock_bridge_setup) -> None: @@ -55,15 +55,15 @@ async def test_unload_entry(hass: HomeAssistant, mock_bridge_setup) -> None: assert await async_setup_component(hass, hue.DOMAIN, {}) is True assert len(mock_bridge_setup.mock_calls) == 1 - hass.data[hue.DOMAIN] = {entry.entry_id: mock_bridge_setup} + entry.runtime_data = mock_bridge_setup async def mock_reset(): - hass.data[hue.DOMAIN].pop(entry.entry_id) + delattr(entry, "runtime_data") return True mock_bridge_setup.async_reset = mock_reset assert await hue.async_unload_entry(hass, entry) - assert hue.DOMAIN not in hass.data + assert not hasattr(entry, "runtime_data") async def test_setting_unique_id(hass: HomeAssistant, mock_bridge_setup) -> None: diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index a9fc1e5c70b..2a366f96e53 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -185,7 +185,7 @@ async def setup_bridge(hass: HomeAssistant, mock_bridge_v1: Mock) -> None: ) config_entry.mock_state(hass, ConfigEntryState.LOADED) mock_bridge_v1.config_entry = config_entry - hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge_v1} + config_entry.runtime_data = mock_bridge_v1 await hass.config_entries.async_forward_entry_setups(config_entry, ["light"]) # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/components/hue/test_services.py b/tests/components/hue/test_services.py index 26a4cab8261..2fd8379a73a 100644 --- a/tests/components/hue/test_services.py +++ b/tests/components/hue/test_services.py @@ -9,6 +9,7 @@ from homeassistant.components.hue.const import ( CONF_ALLOW_UNREACHABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonArrayType from .conftest import setup_bridge, setup_component @@ -190,6 +191,7 @@ async def test_hue_multi_bridge_activate_scene_all_respond( mock_bridge_v2: Mock, mock_config_entry_v1: MockConfigEntry, mock_config_entry_v2: MockConfigEntry, + v2_resources_test_data: JsonArrayType, ) -> None: """Test that makes multiple bridges successfully activate a scene.""" await setup_component(hass) @@ -198,6 +200,8 @@ async def test_hue_multi_bridge_activate_scene_all_respond( mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) @@ -224,6 +228,7 @@ async def test_hue_multi_bridge_activate_scene_one_responds( mock_bridge_v2: Mock, mock_config_entry_v1: MockConfigEntry, mock_config_entry_v2: MockConfigEntry, + v2_resources_test_data: JsonArrayType, ) -> None: """Test that makes only one bridge successfully activate a scene.""" await setup_component(hass) @@ -232,6 +237,8 @@ async def test_hue_multi_bridge_activate_scene_one_responds( mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) @@ -257,6 +264,7 @@ async def test_hue_multi_bridge_activate_scene_zero_responds( mock_bridge_v2: Mock, mock_config_entry_v1: MockConfigEntry, mock_config_entry_v2: MockConfigEntry, + v2_resources_test_data: JsonArrayType, ) -> None: """Test that makes no bridge successfully activate a scene.""" await setup_component(hass) @@ -264,6 +272,8 @@ async def test_hue_multi_bridge_activate_scene_zero_responds( mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) diff --git a/tests/components/huisbaasje/test_init.py b/tests/components/huisbaasje/test_init.py index 5f1bcb0094d..245cde5e9af 100644 --- a/tests/components/huisbaasje/test_init.py +++ b/tests/components/huisbaasje/test_init.py @@ -4,24 +4,16 @@ from unittest.mock import patch from energyflip import EnergyFlipException -from homeassistant.components import huisbaasje +from homeassistant.components.huisbaasje.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_data import MOCK_CURRENT_MEASUREMENTS from tests.common import MockConfigEntry -async def test_setup(hass: HomeAssistant) -> None: - """Test for successfully setting up the platform.""" - assert await async_setup_component(hass, huisbaasje.DOMAIN, {}) - await hass.async_block_till_done() - assert huisbaasje.DOMAIN in hass.config.components - - async def test_setup_entry(hass: HomeAssistant) -> None: """Test for successfully setting a config entry.""" with ( @@ -36,10 +28,9 @@ async def test_setup_entry(hass: HomeAssistant) -> None: return_value=MOCK_CURRENT_MEASUREMENTS, ) as mock_current_measurements, ): - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", @@ -56,9 +47,6 @@ async def test_setup_entry(hass: HomeAssistant) -> None: # Assert integration is loaded assert config_entry.state is ConfigEntryState.LOADED - assert huisbaasje.DOMAIN in hass.config.components - assert huisbaasje.DOMAIN in hass.data - assert config_entry.entry_id in hass.data[huisbaasje.DOMAIN] # Assert entities are loaded entities = hass.states.async_entity_ids("sensor") @@ -75,10 +63,9 @@ async def test_setup_entry_error(hass: HomeAssistant) -> None: with patch( "energyflip.EnergyFlip.authenticate", side_effect=EnergyFlipException ) as mock_authenticate: - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", @@ -95,7 +82,7 @@ async def test_setup_entry_error(hass: HomeAssistant) -> None: # Assert integration is loaded with error assert config_entry.state is ConfigEntryState.SETUP_ERROR - assert huisbaasje.DOMAIN not in hass.data + assert DOMAIN not in hass.data # Assert entities are not loaded entities = hass.states.async_entity_ids("sensor") @@ -119,10 +106,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None: return_value=MOCK_CURRENT_MEASUREMENTS, ) as mock_current_measurements, ): - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 5f5707bdd5d..4302efa98c8 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from homeassistant.components import huisbaasje +from homeassistant.components.huisbaasje.const import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -40,10 +40,9 @@ async def test_setup_entry(hass: HomeAssistant) -> None: return_value=MOCK_CURRENT_MEASUREMENTS, ) as mock_current_measurements, ): - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", @@ -331,10 +330,9 @@ async def test_setup_entry_absent_measurement(hass: HomeAssistant) -> None: return_value=MOCK_LIMITED_CURRENT_MEASUREMENTS, ) as mock_current_measurements, ): - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 871f108bfd0..1cd6f9b393e 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -3,10 +3,10 @@ import asyncio from collections.abc import Generator import time -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, create_autospec, patch +from aioautomower.commands import MowerCommands, WorkAreaSettings from aioautomower.model import MowerAttributes -from aioautomower.session import AutomowerSession, MowerCommands from aioautomower.utils import mower_list_to_dictionary_dataclass from aiohttp import ClientWebSocketResponse import pytest @@ -108,7 +108,9 @@ async def setup_credentials(hass: HomeAssistant) -> None: @pytest.fixture -def mock_automower_client(values) -> Generator[AsyncMock]: +def mock_automower_client( + values: dict[str, MowerAttributes], +) -> Generator[AsyncMock]: """Mock a Husqvarna Automower client.""" async def listen() -> None: @@ -117,37 +119,21 @@ def mock_automower_client(values) -> Generator[AsyncMock]: await listen_block.wait() pytest.fail("Listen was not cancelled!") - mock = AsyncMock(spec=AutomowerSession) - mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) - mock.commands = AsyncMock(spec_set=MowerCommands) - mock.get_status.return_value = values - mock.start_listening = AsyncMock(side_effect=listen) - with patch( "homeassistant.components.husqvarna_automower.AutomowerSession", - return_value=mock, - ): - yield mock - - -@pytest.fixture -def mock_automower_client_one_mower(values) -> Generator[AsyncMock]: - """Mock a Husqvarna Automower client.""" - - async def listen() -> None: - """Mock listen.""" - listen_block = asyncio.Event() - await listen_block.wait() - pytest.fail("Listen was not cancelled!") - - mock = AsyncMock(spec=AutomowerSession) - mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) - mock.commands = AsyncMock(spec_set=MowerCommands) - mock.get_status.return_value = values - mock.start_listening = AsyncMock(side_effect=listen) - - with patch( - "homeassistant.components.husqvarna_automower.AutomowerSession", - return_value=mock, - ): - yield mock + autospec=True, + spec_set=True, + ) as mock: + mock_instance = mock.return_value + mock_instance.auth = AsyncMock(side_effect=ClientWebSocketResponse) + mock_instance.get_status = AsyncMock(return_value=values) + mock_instance.start_listening = AsyncMock(side_effect=listen) + mock_instance.commands = create_autospec( + MowerCommands, instance=True, spec_set=True + ) + mock_instance.commands.workarea_settings.return_value = create_autospec( + WorkAreaSettings, + instance=True, + spec_set=True, + ) + yield mock_instance diff --git a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr index a077eb134d4..6c4e8e9e308 100644 --- a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charging', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_battery_charging', @@ -75,6 +76,7 @@ 'original_name': 'Leaving dock', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leaving_dock', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_leaving_dock', @@ -94,53 +96,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_returning_to_dock-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_mower_1_returning_to_dock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Returning to dock', - 'platform': 'husqvarna_automower', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'returning_to_dock', - 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_returning_to_dock', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_returning_to_dock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Mower 1 Returning to dock', - }), - 'context': , - 'entity_id': 'binary_sensor.test_mower_1_returning_to_dock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_charging-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -169,6 +124,7 @@ 'original_name': 'Charging', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234_battery_charging', @@ -217,6 +173,7 @@ 'original_name': 'Leaving dock', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leaving_dock', 'unique_id': '1234_leaving_dock', @@ -236,50 +193,3 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_returning_to_dock-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_mower_2_returning_to_dock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Returning to dock', - 'platform': 'husqvarna_automower', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'returning_to_dock', - 'unique_id': '1234_returning_to_dock', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_returning_to_dock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Mower 2 Returning to dock', - }), - 'context': , - 'entity_id': 'binary_sensor.test_mower_2_returning_to_dock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr index 088850c1e07..3d48125aa9a 100644 --- a/tests/components/husqvarna_automower/snapshots/test_button.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Confirm error', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'confirm_error', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_confirm_error', @@ -74,6 +75,7 @@ 'original_name': 'Sync clock', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sync_clock', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_sync_clock', @@ -121,6 +123,7 @@ 'original_name': 'Sync clock', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sync_clock', 'unique_id': '1234_sync_clock', diff --git a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr index e94eea4087c..acdf083f52c 100644 --- a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0', diff --git a/tests/components/husqvarna_automower/snapshots/test_number.ambr b/tests/components/husqvarna_automower/snapshots/test_number.ambr index 291aef83dbf..f0f45110b80 100644 --- a/tests/components/husqvarna_automower/snapshots/test_number.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Back lawn cutting height', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'work_area_cutting_height_work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_654321_cutting_height_work_area', @@ -89,6 +90,7 @@ 'original_name': 'Cutting height', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cutting_height', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_cutting_height', @@ -145,6 +147,7 @@ 'original_name': 'Front lawn cutting height', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'work_area_cutting_height_work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_123456_cutting_height_work_area', @@ -202,6 +205,7 @@ 'original_name': 'My lawn cutting height', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'my_lawn_cutting_height_work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_0_cutting_height_work_area', diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 979d40a53d8..109e6614545 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_battery_percent', @@ -75,6 +76,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -84,6 +88,7 @@ 'original_name': 'Cutting blade usage time', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cutting_blade_usage_time', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_cutting_blade_usage_time', @@ -103,7 +108,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.034', + 'state': '0.0341666666666667', }) # --- # name: test_sensor_snapshot[sensor.test_mower_1_downtime-entry] @@ -142,6 +147,7 @@ 'original_name': 'Downtime', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'downtime', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_downtime', @@ -325,6 +331,7 @@ 'original_name': 'Error', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_error', @@ -505,6 +512,7 @@ 'original_name': 'Front lawn last time completed', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'work_area_last_time_completed', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_123456_last_time_completed', @@ -555,6 +563,7 @@ 'original_name': 'Front lawn progress', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'work_area_progress', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_123456_progress', @@ -612,6 +621,7 @@ 'original_name': 'Mode', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_mode', @@ -667,6 +677,7 @@ 'original_name': 'My lawn last time completed', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'my_lawn_last_time_completed', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_0_last_time_completed', @@ -717,6 +728,7 @@ 'original_name': 'My lawn progress', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'my_lawn_progress', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_0_progress', @@ -766,6 +778,7 @@ 'original_name': 'Next start', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'next_start_timestamp', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_next_start_timestamp', @@ -816,6 +829,7 @@ 'original_name': 'Number of charging cycles', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'number_of_charging_cycles', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_number_of_charging_cycles', @@ -866,6 +880,7 @@ 'original_name': 'Number of collisions', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'number_of_collisions', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_number_of_collisions', @@ -927,6 +942,7 @@ 'original_name': 'Restricted reason', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'restricted_reason', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_restricted_reason', @@ -983,6 +999,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -992,6 +1011,7 @@ 'original_name': 'Total charging time', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_charging_time', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_total_charging_time', @@ -1011,7 +1031,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1204.000', + 'state': '1204.0', }) # --- # name: test_sensor_snapshot[sensor.test_mower_1_total_cutting_time-entry] @@ -1038,6 +1058,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1047,6 +1070,7 @@ 'original_name': 'Total cutting time', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_cutting_time', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_total_cutting_time', @@ -1066,7 +1090,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1165.000', + 'state': '1165.0', }) # --- # name: test_sensor_snapshot[sensor.test_mower_1_total_drive_distance-entry] @@ -1093,6 +1117,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1102,6 +1129,7 @@ 'original_name': 'Total drive distance', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_drive_distance', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_total_drive_distance', @@ -1148,6 +1176,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1157,6 +1188,7 @@ 'original_name': 'Total running time', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_running_time', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_total_running_time', @@ -1176,7 +1208,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1268.000', + 'state': '1268.0', }) # --- # name: test_sensor_snapshot[sensor.test_mower_1_total_searching_time-entry] @@ -1203,6 +1235,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1212,6 +1247,7 @@ 'original_name': 'Total searching time', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_searching_time', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_total_searching_time', @@ -1231,7 +1267,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '103.000', + 'state': '103.0', }) # --- # name: test_sensor_snapshot[sensor.test_mower_1_uptime-entry] @@ -1270,6 +1306,7 @@ 'original_name': 'Uptime', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uptime', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_uptime', @@ -1327,6 +1364,7 @@ 'original_name': 'Work area', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_work_area', @@ -1388,6 +1426,7 @@ 'original_name': 'Battery', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234_battery_percent', @@ -1571,6 +1610,7 @@ 'original_name': 'Error', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'error', 'unique_id': '1234_error', @@ -1759,6 +1799,7 @@ 'original_name': 'Mode', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '1234_mode', @@ -1814,6 +1855,7 @@ 'original_name': 'Next start', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'next_start_timestamp', 'unique_id': '1234_next_start_timestamp', @@ -1875,6 +1917,7 @@ 'original_name': 'Restricted reason', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'restricted_reason', 'unique_id': '1234_restricted_reason', diff --git a/tests/components/husqvarna_automower/snapshots/test_switch.ambr b/tests/components/husqvarna_automower/snapshots/test_switch.ambr index 5e01694e924..a876fc4c1b6 100644 --- a/tests/components/husqvarna_automower/snapshots/test_switch.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Avoid Danger Zone', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stay_out_zones', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_AAAAAAAA-BBBB-CCCC-DDDD-123456789101_stay_out_zones', @@ -74,6 +75,7 @@ 'original_name': 'Avoid Springflowers', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stay_out_zones', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_81C6EEA2-D139-4FEA-B134-F22A6B3EA403_stay_out_zones', @@ -121,6 +123,7 @@ 'original_name': 'Back lawn', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'work_area_work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_654321_work_area', @@ -168,6 +171,7 @@ 'original_name': 'Enable schedule', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'enable_schedule', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_enable_schedule', @@ -215,6 +219,7 @@ 'original_name': 'Front lawn', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'work_area_work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_123456_work_area', @@ -262,6 +267,7 @@ 'original_name': 'My lawn', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'my_lawn_work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_0_work_area', @@ -309,6 +315,7 @@ 'original_name': 'Enable schedule', 'platform': 'husqvarna_automower', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'enable_schedule', 'unique_id': '1234_enable_schedule', diff --git a/tests/components/husqvarna_automower/test_binary_sensor.py b/tests/components/husqvarna_automower/test_binary_sensor.py index 30c9cc1bdd3..3d40da99dcb 100644 --- a/tests/components/husqvarna_automower/test_binary_sensor.py +++ b/tests/components/husqvarna_automower/test_binary_sensor.py @@ -2,54 +2,16 @@ from unittest.mock import AsyncMock, patch -from aioautomower.model import MowerActivities, MowerAttributes -from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .const import TEST_MOWER_ID -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_binary_sensor_states( - hass: HomeAssistant, - mock_automower_client: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - values: dict[str, MowerAttributes], -) -> None: - """Test binary sensor states.""" - await setup_integration(hass, mock_config_entry) - state = hass.states.get("binary_sensor.test_mower_1_charging") - assert state is not None - assert state.state == "off" - state = hass.states.get("binary_sensor.test_mower_1_leaving_dock") - assert state is not None - assert state.state == "off" - state = hass.states.get("binary_sensor.test_mower_1_returning_to_dock") - assert state is not None - assert state.state == "off" - - for activity, entity in ( - (MowerActivities.CHARGING, "test_mower_1_charging"), - (MowerActivities.LEAVING, "test_mower_1_leaving_dock"), - (MowerActivities.GOING_HOME, "test_mower_1_returning_to_dock"), - ): - values[TEST_MOWER_ID].mower.activity = activity - 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(f"binary_sensor.{entity}") - assert state.state == "on" +from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 5bef810150d..9fb5ad28c89 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -7,7 +7,7 @@ from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL @@ -64,14 +64,11 @@ async def test_button_states_and_commands( target={ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mocked_method = getattr(mock_automower_client.commands, "error_confirm") - mocked_method.assert_called_once_with(TEST_MOWER_ID) + mock_automower_client.commands.error_confirm.assert_called_once_with(TEST_MOWER_ID) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2023-06-05T00:16:00+00:00" - getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiError( - "Test error" - ) + mock_automower_client.commands.error_confirm.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -106,8 +103,7 @@ async def test_sync_clock( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mocked_method = mock_automower_client.commands.set_datetime - mocked_method.assert_called_once_with(TEST_MOWER_ID) + mock_automower_client.commands.set_datetime.assert_called_once_with(TEST_MOWER_ID) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2024-02-29T11:00:00+00:00" diff --git a/tests/components/husqvarna_automower/test_calendar.py b/tests/components/husqvarna_automower/test_calendar.py index 8138b8c139b..8f9a3e6a016 100644 --- a/tests/components/husqvarna_automower/test_calendar.py +++ b/tests/components/husqvarna_automower/test_calendar.py @@ -11,7 +11,7 @@ import zoneinfo from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, diff --git a/tests/components/husqvarna_automower/test_device_tracker.py b/tests/components/husqvarna_automower/test_device_tracker.py index 91f5e40b154..3ab5e55f2c7 100644 --- a/tests/components/husqvarna_automower/test_device_tracker.py +++ b/tests/components/husqvarna_automower/test_device_tracker.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ec1fb7391b4..ecb92bb39cf 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -33,6 +33,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker ADDITIONAL_NUMBER_ENTITIES = 1 ADDITIONAL_SENSOR_ENTITIES = 2 ADDITIONAL_SWITCH_ENTITIES = 1 +NUMBER_OF_ENTITIES_MOWER_2 = 11 async def test_load_unload_entry( @@ -250,7 +251,7 @@ async def test_coordinator_automatic_registry_cleanup( assert ( len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) - == current_entites - 12 + == current_entites - NUMBER_OF_ENTITIES_MOWER_2 ) assert ( len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) @@ -278,7 +279,10 @@ async def test_coordinator_automatic_registry_cleanup( async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == NUMBER_OF_ENTITIES_MOWER_2 + ) assert ( len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == current_devices - 1 diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index de7479bf908..c62cf6653c4 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -95,9 +95,7 @@ async def test_lawn_mower_commands( mocked_method = getattr(mock_automower_client.commands, aioautomower_command) mocked_method.assert_called_once_with(TEST_MOWER_ID) - getattr( - mock_automower_client.commands, aioautomower_command - ).side_effect = ApiError("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -144,8 +142,7 @@ async def test_lawn_mower_service_commands( ) -> None: """Test lawn_mower commands.""" await setup_integration(hass, mock_config_entry) - mocked_method = AsyncMock() - setattr(mock_automower_client.commands, aioautomower_command, mocked_method) + mocked_method = getattr(mock_automower_client.commands, aioautomower_command) await hass.services.async_call( domain=DOMAIN, service=service, @@ -155,9 +152,7 @@ async def test_lawn_mower_service_commands( ) mocked_method.assert_called_once_with(TEST_MOWER_ID, extra_data) - getattr( - mock_automower_client.commands, aioautomower_command - ).side_effect = ApiError("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -198,8 +193,7 @@ async def test_lawn_mower_override_work_area_command( ) -> None: """Test lawn_mower work area override commands.""" await setup_integration(hass, mock_config_entry) - mocked_method = AsyncMock() - setattr(mock_automower_client.commands, aioautomower_command, mocked_method) + mocked_method = getattr(mock_automower_client.commands, aioautomower_command) await hass.services.async_call( domain=DOMAIN, service=service, diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index 628011e3f15..227010e939d 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -7,7 +7,7 @@ from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.const import EXECUTION_TIME_DELAY from homeassistant.const import Platform @@ -96,7 +96,7 @@ async def test_number_workarea_commands( service_data={"value": "75"}, blocking=True, ) - assert len(mocked_method.mock_calls) == 2 + assert mock_automower_client.commands.workarea_settings.call_count == 2 @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/husqvarna_automower/test_select.py b/tests/components/husqvarna_automower/test_select.py index 01e7607735b..f1b855a90a3 100644 --- a/tests/components/husqvarna_automower/test_select.py +++ b/tests/components/husqvarna_automower/test_select.py @@ -74,7 +74,7 @@ async def test_select_commands( blocking=True, ) mocked_method = mock_automower_client.commands.set_headlight_mode - mocked_method.assert_called_once_with(TEST_MOWER_ID, service.upper()) + mocked_method.assert_called_once_with(TEST_MOWER_ID, service) assert len(mocked_method.mock_calls) == 1 mocked_method.side_effect = ApiError("Test error") diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index 85d20178e73..b1029f5919b 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -7,7 +7,7 @@ import zoneinfo from aioautomower.model import MowerAttributes, MowerModes, MowerStates from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN, Platform @@ -53,7 +53,7 @@ async def test_cutting_blade_usage_time_sensor( await setup_integration(hass, mock_config_entry) state = hass.states.get("sensor.test_mower_1_cutting_blade_usage_time") assert state is not None - assert state.state == "0.034" + assert float(state.state) == pytest.approx(0.03416666) @pytest.mark.freeze_time( diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index 00b04ce9903..d6ca8ff36e2 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -9,7 +9,7 @@ from aioautomower.model import MowerAttributes, MowerModes, Zone from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.const import ( DOMAIN, @@ -133,8 +133,7 @@ async def test_stay_out_zone_switch_commands( ) values[TEST_MOWER_ID].stay_out_zones.zones[TEST_ZONE_ID].enabled = boolean mock_automower_client.get_status.return_value = values - mocked_method = AsyncMock() - setattr(mock_automower_client.commands, "switch_stay_out_zone", mocked_method) + mocked_method = mock_automower_client.commands.switch_stay_out_zone await hass.services.async_call( domain=SWITCH_DOMAIN, service=service, diff --git a/tests/components/hydrawise/snapshots/test_binary_sensor.ambr b/tests/components/hydrawise/snapshots/test_binary_sensor.ambr index 84e52a7f966..30adfea90be 100644 --- a/tests/components/hydrawise/snapshots/test_binary_sensor.ambr +++ b/tests/components/hydrawise/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Connectivity', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '52496_status', @@ -76,6 +77,7 @@ 'original_name': 'Rain sensor', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rain_sensor', 'unique_id': '52496_rain_sensor', @@ -125,6 +127,7 @@ 'original_name': 'Watering', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'watering', 'unique_id': '5965394_is_watering', @@ -174,6 +177,7 @@ 'original_name': 'Watering', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'watering', 'unique_id': '5965395_is_watering', diff --git a/tests/components/hydrawise/snapshots/test_sensor.ambr b/tests/components/hydrawise/snapshots/test_sensor.ambr index 3e475b1eeb1..e2e97da120c 100644 --- a/tests/components/hydrawise/snapshots/test_sensor.ambr +++ b/tests/components/hydrawise/snapshots/test_sensor.ambr @@ -33,6 +33,7 @@ 'original_name': 'Daily active water use', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_active_water_use', 'unique_id': '52496_daily_active_water_use', @@ -77,12 +78,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Daily active watering time', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_active_water_time', 'unique_id': '52496_daily_active_water_time', @@ -139,6 +144,7 @@ 'original_name': 'Daily inactive water use', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_inactive_water_use', 'unique_id': '52496_daily_inactive_water_use', @@ -195,6 +201,7 @@ 'original_name': 'Daily total water use', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_total_water_use', 'unique_id': '52496_daily_total_water_use', @@ -251,6 +258,7 @@ 'original_name': 'Daily active water use', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_active_water_use', 'unique_id': '5965394_daily_active_water_use', @@ -295,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Daily active watering time', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_active_water_time', 'unique_id': '5965394_daily_active_water_time', @@ -351,6 +363,7 @@ 'original_name': 'Next cycle', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'next_cycle', 'unique_id': '5965394_next_cycle', @@ -400,6 +413,7 @@ 'original_name': 'Remaining watering time', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'watering_time', 'unique_id': '5965394_watering_time', @@ -455,6 +469,7 @@ 'original_name': 'Daily active water use', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_active_water_use', 'unique_id': '5965395_daily_active_water_use', @@ -500,12 +515,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Daily active watering time', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_active_water_time', 'unique_id': '5965395_daily_active_water_time', @@ -556,6 +575,7 @@ 'original_name': 'Next cycle', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'next_cycle', 'unique_id': '5965395_next_cycle', @@ -605,6 +625,7 @@ 'original_name': 'Remaining watering time', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'watering_time', 'unique_id': '5965395_watering_time', diff --git a/tests/components/hydrawise/snapshots/test_switch.ambr b/tests/components/hydrawise/snapshots/test_switch.ambr index 9ad37ddbfbf..684e1d3ac3e 100644 --- a/tests/components/hydrawise/snapshots/test_switch.ambr +++ b/tests/components/hydrawise/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Automatic watering', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_watering', 'unique_id': '5965394_auto_watering', @@ -76,6 +77,7 @@ 'original_name': 'Manual watering', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'manual_watering', 'unique_id': '5965394_manual_watering', @@ -125,6 +127,7 @@ 'original_name': 'Automatic watering', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_watering', 'unique_id': '5965395_auto_watering', @@ -174,6 +177,7 @@ 'original_name': 'Manual watering', 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'manual_watering', 'unique_id': '5965395_manual_watering', diff --git a/tests/components/hydrawise/snapshots/test_valve.ambr b/tests/components/hydrawise/snapshots/test_valve.ambr index 197e7796a07..558c8f12a56 100644 --- a/tests/components/hydrawise/snapshots/test_valve.ambr +++ b/tests/components/hydrawise/snapshots/test_valve.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '5965394_zone', @@ -77,6 +78,7 @@ 'original_name': None, 'platform': 'hydrawise', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '5965395_zone', diff --git a/tests/components/igloohome/snapshots/test_lock.ambr b/tests/components/igloohome/snapshots/test_lock.ambr index 5d94cf27c6b..1d539049411 100644 --- a/tests/components/igloohome/snapshots/test_lock.ambr +++ b/tests/components/igloohome/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'igloohome', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'lock_OE1X123cbb11', diff --git a/tests/components/igloohome/snapshots/test_sensor.ambr b/tests/components/igloohome/snapshots/test_sensor.ambr index 9e17343d4fa..c2954ad5f15 100644 --- a/tests/components/igloohome/snapshots/test_sensor.ambr +++ b/tests/components/igloohome/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'igloohome', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'battery_OE1X123cbb11', diff --git a/tests/components/igloohome/test_lock.py b/tests/components/igloohome/test_lock.py index 324a4ab231a..621f9995190 100644 --- a/tests/components/igloohome/test_lock.py +++ b/tests/components/igloohome/test_lock.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/igloohome/test_sensor.py b/tests/components/igloohome/test_sensor.py index bfc60574450..21ea3efbf8e 100644 --- a/tests/components/igloohome/test_sensor.py +++ b/tests/components/igloohome/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/imeon_inverter/conftest.py b/tests/components/imeon_inverter/conftest.py index 38fb0d90322..5d1dacc4e69 100644 --- a/tests/components/imeon_inverter/conftest.py +++ b/tests/components/imeon_inverter/conftest.py @@ -60,6 +60,7 @@ def mock_imeon_inverter() -> Generator[MagicMock]: inverter.__aenter__.return_value = inverter inverter.login.return_value = True inverter.get_serial.return_value = TEST_SERIAL + inverter.inverter.get.return_value = {"inverter": "blah", "software": "1.0"} inverter.storage = load_json_object_fixture("sensor_data.json", DOMAIN) yield inverter diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr index 38f50df5407..8816889f049 100644 --- a/tests/components/imeon_inverter/snapshots/test_sensor.ambr +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Air temperature', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_air_temperature', 'unique_id': '111111111111111_temp_air_temperature', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery autonomy', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': '111111111111111_battery_autonomy', @@ -127,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery charge time', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_charge_time', 'unique_id': '111111111111111_battery_charge_time', @@ -179,12 +191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery power', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_power', 'unique_id': '111111111111111_battery_power', @@ -237,6 +253,7 @@ 'original_name': 'Battery state of charge', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_soc', 'unique_id': '111111111111111_battery_soc', @@ -265,7 +282,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -283,12 +300,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery stored', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_stored', 'unique_id': '111111111111111_battery_stored', @@ -300,7 +321,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy_storage', 'friendly_name': 'Imeon inverter Battery stored', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -335,12 +356,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging current limit', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'inverter_charging_current_limit', 'unique_id': '111111111111111_inverter_charging_current_limit', @@ -387,12 +412,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Component temperature', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_component_temperature', 'unique_id': '111111111111111_temp_component_temperature', @@ -439,12 +468,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Grid current L1', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_current_l1', 'unique_id': '111111111111111_grid_current_l1', @@ -491,12 +524,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Grid current L2', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_current_l2', 'unique_id': '111111111111111_grid_current_l2', @@ -543,12 +580,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Grid current L3', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_current_l3', 'unique_id': '111111111111111_grid_current_l3', @@ -595,12 +636,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Grid frequency', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_frequency', 'unique_id': '111111111111111_grid_frequency', @@ -647,12 +692,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Grid voltage L1', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_voltage_l1', 'unique_id': '111111111111111_grid_voltage_l1', @@ -699,12 +748,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Grid voltage L2', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_voltage_l2', 'unique_id': '111111111111111_grid_voltage_l2', @@ -751,12 +804,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Grid voltage L3', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_voltage_l3', 'unique_id': '111111111111111_grid_voltage_l3', @@ -803,12 +860,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Injection power limit', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'inverter_injection_power_limit', 'unique_id': '111111111111111_inverter_injection_power_limit', @@ -855,12 +916,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Input power L1', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'input_power_l1', 'unique_id': '111111111111111_input_power_l1', @@ -907,12 +972,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Input power L2', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'input_power_l2', 'unique_id': '111111111111111_input_power_l2', @@ -959,12 +1028,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Input power L3', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'input_power_l3', 'unique_id': '111111111111111_input_power_l3', @@ -1011,12 +1084,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Input power total', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'input_power_total', 'unique_id': '111111111111111_input_power_total', @@ -1063,12 +1140,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Meter power', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_power', 'unique_id': '111111111111111_meter_power', @@ -1115,12 +1196,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Meter power protocol', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_power_protocol', 'unique_id': '111111111111111_meter_power_protocol', @@ -1176,6 +1261,7 @@ 'original_name': 'Monitoring building consumption', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_building_consumption', 'unique_id': '111111111111111_monitoring_building_consumption', @@ -1231,6 +1317,7 @@ 'original_name': 'Monitoring building consumption (minute)', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_minute_building_consumption', 'unique_id': '111111111111111_monitoring_minute_building_consumption', @@ -1286,6 +1373,7 @@ 'original_name': 'Monitoring economy factor', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_economy_factor', 'unique_id': '111111111111111_monitoring_economy_factor', @@ -1340,6 +1428,7 @@ 'original_name': 'Monitoring grid consumption', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_grid_consumption', 'unique_id': '111111111111111_monitoring_grid_consumption', @@ -1395,6 +1484,7 @@ 'original_name': 'Monitoring grid consumption (minute)', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_minute_grid_consumption', 'unique_id': '111111111111111_monitoring_minute_grid_consumption', @@ -1450,6 +1540,7 @@ 'original_name': 'Monitoring grid injection', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_grid_injection', 'unique_id': '111111111111111_monitoring_grid_injection', @@ -1505,6 +1596,7 @@ 'original_name': 'Monitoring grid injection (minute)', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_minute_grid_injection', 'unique_id': '111111111111111_monitoring_minute_grid_injection', @@ -1560,6 +1652,7 @@ 'original_name': 'Monitoring grid power flow', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_grid_power_flow', 'unique_id': '111111111111111_monitoring_grid_power_flow', @@ -1615,6 +1708,7 @@ 'original_name': 'Monitoring grid power flow (minute)', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_minute_grid_power_flow', 'unique_id': '111111111111111_monitoring_minute_grid_power_flow', @@ -1670,6 +1764,7 @@ 'original_name': 'Monitoring self-consumption', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_self_consumption', 'unique_id': '111111111111111_monitoring_self_consumption', @@ -1724,6 +1819,7 @@ 'original_name': 'Monitoring self-sufficiency', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_self_sufficiency', 'unique_id': '111111111111111_monitoring_self_sufficiency', @@ -1778,6 +1874,7 @@ 'original_name': 'Monitoring solar production', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_solar_production', 'unique_id': '111111111111111_monitoring_solar_production', @@ -1833,6 +1930,7 @@ 'original_name': 'Monitoring solar production (minute)', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monitoring_minute_solar_production', 'unique_id': '111111111111111_monitoring_minute_solar_production', @@ -1879,12 +1977,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output current L1', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_current_l1', 'unique_id': '111111111111111_output_current_l1', @@ -1931,12 +2033,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output current L2', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_current_l2', 'unique_id': '111111111111111_output_current_l2', @@ -1983,12 +2089,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output current L3', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_current_l3', 'unique_id': '111111111111111_output_current_l3', @@ -2035,12 +2145,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output frequency', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_frequency', 'unique_id': '111111111111111_output_frequency', @@ -2087,12 +2201,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output power L1', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_power_l1', 'unique_id': '111111111111111_output_power_l1', @@ -2139,12 +2257,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output power L2', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_power_l2', 'unique_id': '111111111111111_output_power_l2', @@ -2191,12 +2313,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output power L3', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_power_l3', 'unique_id': '111111111111111_output_power_l3', @@ -2243,12 +2369,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output power total', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_power_total', 'unique_id': '111111111111111_output_power_total', @@ -2295,12 +2425,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output voltage L1', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_voltage_l1', 'unique_id': '111111111111111_output_voltage_l1', @@ -2347,12 +2481,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output voltage L2', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_voltage_l2', 'unique_id': '111111111111111_output_voltage_l2', @@ -2399,12 +2537,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output voltage L3', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_voltage_l3', 'unique_id': '111111111111111_output_voltage_l3', @@ -2451,12 +2593,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'PV consumed', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pv_consumed', 'unique_id': '111111111111111_pv_consumed', @@ -2503,12 +2649,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'PV injected', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pv_injected', 'unique_id': '111111111111111_pv_injected', @@ -2555,12 +2705,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'PV power 1', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pv_power_1', 'unique_id': '111111111111111_pv_power_1', @@ -2607,12 +2761,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'PV power 2', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pv_power_2', 'unique_id': '111111111111111_pv_power_2', @@ -2659,12 +2817,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'PV power total', 'platform': 'imeon_inverter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pv_power_total', 'unique_id': '111111111111111_pv_power_total', diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index ccc6e46befa..5b588af4518 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -32,6 +32,7 @@ 'original_name': 'Water level', 'platform': 'imgw_pib', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_level', 'unique_id': '123_water_level', @@ -88,6 +89,7 @@ 'original_name': 'Water temperature', 'platform': 'imgw_pib', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_temperature', 'unique_id': '123_water_temperature', diff --git a/tests/components/imgw_pib/test_diagnostics.py b/tests/components/imgw_pib/test_diagnostics.py index 14d4e7a5224..2b2568050f3 100644 --- a/tests/components/imgw_pib/test_diagnostics.py +++ b/tests/components/imgw_pib/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py index a1920f38006..cb27f0f9b46 100644 --- a/tests/components/imgw_pib/test_sensor.py +++ b/tests/components/imgw_pib/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from imgw_pib import ApiError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.imgw_pib.const import DOMAIN, UPDATE_INTERVAL from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM diff --git a/tests/components/immich/__init__.py b/tests/components/immich/__init__.py new file mode 100644 index 00000000000..604ab84d68d --- /dev/null +++ b/tests/components/immich/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Immich 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/immich/conftest.py b/tests/components/immich/conftest.py new file mode 100644 index 00000000000..f8f959e0b0a --- /dev/null +++ b/tests/components/immich/conftest.py @@ -0,0 +1,194 @@ +"""Common fixtures for the Immich tests.""" + +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, patch + +from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers +from aioimmich.server.models import ( + ImmichServerAbout, + ImmichServerStatistics, + ImmichServerStorage, +) +from aioimmich.users.models import ImmichUserObject +import pytest + +from homeassistant.components.immich.const import DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockStreamReaderChunked + +from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.immich.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "localhost", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "api_key", + CONF_VERIFY_SSL: True, + }, + unique_id="e7ef5713-9dab-4bd4-b899-715b0ca4379e", + title="Someone", + ) + + +@pytest.fixture +def mock_immich_albums() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichAlbums) + mock.async_get_all_albums.return_value = [MOCK_ALBUM_WITHOUT_ASSETS] + mock.async_get_album_info.return_value = MOCK_ALBUM_WITH_ASSETS + return mock + + +@pytest.fixture +def mock_immich_assets() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichAssests) + mock.async_view_asset.return_value = b"xxxx" + mock.async_play_video_stream.return_value = MockStreamReaderChunked(b"xxxx") + return mock + + +@pytest.fixture +def mock_immich_server() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichServer) + mock.async_get_about_info.return_value = ImmichServerAbout.from_dict( + { + "version": "v1.132.3", + "versionUrl": "https://github.com/immich-app/immich/releases/tag/v1.132.3", + "licensed": False, + "build": "14709928600", + "buildUrl": "https://github.com/immich-app/immich/actions/runs/14709928600", + "buildImage": "v1.132.3", + "buildImageUrl": "https://github.com/immich-app/immich/pkgs/container/immich-server", + "repository": "immich-app/immich", + "repositoryUrl": "https://github.com/immich-app/immich", + "sourceRef": "v1.132.3", + "sourceCommit": "02994883fe3f3972323bb6759d0170a4062f5236", + "sourceUrl": "https://github.com/immich-app/immich/commit/02994883fe3f3972323bb6759d0170a4062f5236", + "nodejs": "v22.14.0", + "exiftool": "13.00", + "ffmpeg": "7.0.2-7", + "libvips": "8.16.1", + "imagemagick": "7.1.1-47", + } + ) + mock.async_get_storage_info.return_value = ImmichServerStorage.from_dict( + { + "diskSize": "294.2 GiB", + "diskUse": "142.9 GiB", + "diskAvailable": "136.3 GiB", + "diskSizeRaw": 315926315008, + "diskUseRaw": 153400406016, + "diskAvailableRaw": 146403004416, + "diskUsagePercentage": 48.56, + } + ) + mock.async_get_server_statistics.return_value = ImmichServerStatistics.from_dict( + { + "photos": 27038, + "videos": 1836, + "usage": 119525451912, + "usagePhotos": 54291170551, + "usageVideos": 65234281361, + "usageByUser": [ + { + "userId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "userName": "admin", + "photos": 27038, + "videos": 1836, + "usage": 119525451912, + "usagePhotos": 54291170551, + "usageVideos": 65234281361, + "quotaSizeInBytes": None, + } + ], + } + ) + return mock + + +@pytest.fixture +def mock_immich_user() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichUsers) + mock.async_get_my_user.return_value = ImmichUserObject.from_dict( + { + "id": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "email": "user@immich.local", + "name": "user", + "profileImagePath": "", + "avatarColor": "primary", + "profileChangedAt": "2025-05-11T10:07:46.866Z", + "storageLabel": "user", + "shouldChangePassword": True, + "isAdmin": True, + "createdAt": "2025-05-11T10:07:46.866Z", + "deletedAt": None, + "updatedAt": "2025-05-18T00:59:55.547Z", + "oauthId": "", + "quotaSizeInBytes": None, + "quotaUsageInBytes": 119526467534, + "status": "active", + "license": None, + } + ) + return mock + + +@pytest.fixture +async def mock_immich( + mock_immich_albums: AsyncMock, + mock_immich_assets: AsyncMock, + mock_immich_server: AsyncMock, + mock_immich_user: AsyncMock, +) -> AsyncGenerator[AsyncMock]: + """Mock the Immich API.""" + with ( + patch("homeassistant.components.immich.Immich", autospec=True) as mock_immich, + patch("homeassistant.components.immich.config_flow.Immich", new=mock_immich), + ): + client = mock_immich.return_value + client.albums = mock_immich_albums + client.assets = mock_immich_assets + client.server = mock_immich_server + client.users = mock_immich_user + yield client + + +@pytest.fixture +async def mock_non_admin_immich(mock_immich: AsyncMock) -> AsyncMock: + """Mock the Immich API.""" + mock_immich.users.async_get_my_user.return_value.is_admin = False + return mock_immich + + +@pytest.fixture +async def setup_media_source(hass: HomeAssistant) -> None: + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py new file mode 100644 index 00000000000..97721bc7dbc --- /dev/null +++ b/tests/components/immich/const.py @@ -0,0 +1,115 @@ +"""Constants for the Immich integration tests.""" + +from aioimmich.albums.models import ImmichAlbum + +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) + +MOCK_USER_DATA = { + CONF_URL: "http://localhost", + CONF_API_KEY: "abcdef0123456789", + CONF_VERIFY_SSL: False, +} + +MOCK_CONFIG_ENTRY_DATA = { + CONF_HOST: "localhost", + CONF_API_KEY: "abcdef0123456789", + CONF_PORT: 80, + CONF_SSL: False, + CONF_VERIFY_SSL: False, +} + +ALBUM_DATA = { + "id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "albumName": "My Album", + "albumThumbnailAssetId": "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5", + "albumUsers": [], + "assetCount": 1, + "assets": [], + "createdAt": "2025-05-11T10:13:22.799Z", + "hasSharedLink": False, + "isActivityEnabled": False, + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "owner": { + "id": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "email": "admin@immich.local", + "name": "admin", + "profileImagePath": "", + "avatarColor": "primary", + "profileChangedAt": "2025-05-11T10:07:46.866Z", + }, + "shared": False, + "updatedAt": "2025-05-17T11:26:03.696Z", +} + +MOCK_ALBUM_WITHOUT_ASSETS = ImmichAlbum.from_dict(ALBUM_DATA) + +MOCK_ALBUM_WITH_ASSETS = ImmichAlbum.from_dict( + { + **ALBUM_DATA, + "assets": [ + { + "id": "2e94c203-50aa-4ad2-8e29-56dd74e0eff4", + "deviceAssetId": "web-filename.jpg-1675185639000", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "WEB", + "libraryId": None, + "type": "IMAGE", + "originalPath": "upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/b4/b8/b4b8ef00-8a6d-4056-91ff-7f86dc66e427.jpg", + "originalFileName": "filename.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "1igGFALX8mVGdHc5aChJf5nxNg==", + "fileCreatedAt": "2023-01-31T17:20:37.085+00:00", + "fileModifiedAt": "2023-01-31T17:20:39+00:00", + "localDateTime": "2023-01-31T18:20:37.085+00:00", + "updatedAt": "2025-05-11T10:13:49.590401+00:00", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "duration": "0:00:00.00000", + "exifInfo": {}, + "livePhotoVideoId": None, + "people": [], + "checksum": "HJm7TVOP80S+eiYZnAhWyRaB/Yc=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + { + "id": "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b", + "deviceAssetId": "web-filename.mp4-1675185639000", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "WEB", + "libraryId": None, + "type": "IMAGE", + "originalPath": "upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/b4/b8/b4b8ef00-8a6d-4056-eeff-7f86dc66e427.mp4", + "originalFileName": "filename.mp4", + "originalMimeType": "video/mp4", + "thumbhash": "1igGFALX8mVGdHc5aChJf5nxNg==", + "fileCreatedAt": "2023-01-31T17:20:37.085+00:00", + "fileModifiedAt": "2023-01-31T17:20:39+00:00", + "localDateTime": "2023-01-31T18:20:37.085+00:00", + "updatedAt": "2025-05-11T10:13:49.590401+00:00", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "duration": "0:00:00.00000", + "exifInfo": {}, + "livePhotoVideoId": None, + "people": [], + "checksum": "HJm7TVOP80S+eiYZnAhWyRaB/Yc=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ], + } +) diff --git a/tests/components/immich/snapshots/test_diagnostics.ambr b/tests/components/immich/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b3dd3c47db6 --- /dev/null +++ b/tests/components/immich/snapshots/test_diagnostics.ambr @@ -0,0 +1,78 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'server_about': dict({ + 'build': '14709928600', + 'build_image': 'v1.132.3', + 'build_image_url': 'https://github.com/immich-app/immich/pkgs/container/immich-server', + 'build_url': 'https://github.com/immich-app/immich/actions/runs/14709928600', + 'exiftool': '13.00', + 'ffmpeg': '7.0.2-7', + 'imagemagick': '7.1.1-47', + 'libvips': '8.16.1', + 'licensed': False, + 'nodejs': 'v22.14.0', + 'repository': 'immich-app/immich', + 'repository_url': 'https://github.com/immich-app/immich', + 'source_commit': '02994883fe3f3972323bb6759d0170a4062f5236', + 'source_ref': 'v1.132.3', + 'source_url': 'https://github.com/immich-app/immich/commit/02994883fe3f3972323bb6759d0170a4062f5236', + 'version': 'v1.132.3', + 'version_url': 'https://github.com/immich-app/immich/releases/tag/v1.132.3', + }), + 'server_storage': dict({ + 'disk_available': '136.3 GiB', + 'disk_available_raw': 146403004416, + 'disk_size': '294.2 GiB', + 'disk_size_raw': 315926315008, + 'disk_usage_percentage': 48.56, + 'disk_use': '142.9 GiB', + 'disk_use_raw': 153400406016, + }), + 'server_usage': dict({ + 'photos': 27038, + 'usage': 119525451912, + 'usage_by_user': list([ + dict({ + 'photos': 27038, + 'quota_size_in_bytes': None, + 'usage': 119525451912, + 'usage_photos': 54291170551, + 'usage_videos': 65234281361, + 'user_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e', + 'user_name': 'admin', + 'videos': 1836, + }), + ]), + 'usage_photos': 54291170551, + 'usage_videos': 65234281361, + 'videos': 1836, + }), + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'host': '**REDACTED**', + 'port': 80, + 'ssl': False, + 'verify_ssl': True, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'immich', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Someone', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/immich/snapshots/test_sensor.ambr b/tests/components/immich/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..590e7d9ad5c --- /dev/null +++ b/tests/components/immich/snapshots/test_sensor.ambr @@ -0,0 +1,452 @@ +# serializer version: 1 +# name: test_sensors[sensor.someone_disk_available-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.someone_disk_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk available', + 'platform': 'immich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'disk_available', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_available', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk available', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '136.34842300415', + }) +# --- +# name: test_sensors[sensor.someone_disk_size-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.someone_disk_size', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk size', + 'platform': 'immich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'disk_size', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_size-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_size', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '294.229309082031', + }) +# --- +# name: test_sensors[sensor.someone_disk_usage-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.someone_disk_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk usage', + 'platform': 'immich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'disk_usage', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.someone_disk_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Someone Disk usage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.someone_disk_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.56', + }) +# --- +# name: test_sensors[sensor.someone_disk_used-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.someone_disk_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk used', + 'platform': 'immich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'disk_use', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk used', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_used', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '142.865261077881', + }) +# --- +# name: test_sensors[sensor.someone_disk_used_by_photos-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.someone_disk_used_by_photos', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk used by photos', + 'platform': 'immich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'usage_by_photos', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_usage_by_photos', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_used_by_photos-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk used by photos', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_used_by_photos', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.5625927364454', + }) +# --- +# name: test_sensors[sensor.someone_disk_used_by_videos-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.someone_disk_used_by_videos', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk used by videos', + 'platform': 'immich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'usage_by_videos', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_usage_by_videos', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_used_by_videos-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk used by videos', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_used_by_videos', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.754158870317', + }) +# --- +# name: test_sensors[sensor.someone_photos_count-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.someone_photos_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Photos count', + 'platform': 'immich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'photos_count', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_photos_count', + 'unit_of_measurement': 'photos', + }) +# --- +# name: test_sensors[sensor.someone_photos_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Someone Photos count', + 'state_class': , + 'unit_of_measurement': 'photos', + }), + 'context': , + 'entity_id': 'sensor.someone_photos_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27038', + }) +# --- +# name: test_sensors[sensor.someone_videos_count-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.someone_videos_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Videos count', + 'platform': 'immich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'videos_count', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_videos_count', + 'unit_of_measurement': 'videos', + }) +# --- +# name: test_sensors[sensor.someone_videos_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Someone Videos count', + 'state_class': , + 'unit_of_measurement': 'videos', + }), + 'context': , + 'entity_id': 'sensor.someone_videos_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1836', + }) +# --- diff --git a/tests/components/immich/test_config_flow.py b/tests/components/immich/test_config_flow.py new file mode 100644 index 00000000000..e26cb4df5a1 --- /dev/null +++ b/tests/components/immich/test_config_flow.py @@ -0,0 +1,244 @@ +"""Test the Immich config flow.""" + +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientError +from aioimmich.exceptions import ImmichUnauthorizedError +import pytest + +from homeassistant.components.immich.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 .const import MOCK_CONFIG_ENTRY_DATA, MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_step_user( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock +) -> None: + """Test a user initiated config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user" + assert result["data"] == MOCK_CONFIG_ENTRY_DATA + assert result["result"].unique_id == "e7ef5713-9dab-4bd4-b899-715b0ca4379e" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + ImmichUnauthorizedError( + { + "message": "Invalid API key", + "error": "Unauthenticated", + "statusCode": 401, + "correlationId": "abcdefg", + } + ), + "invalid_auth", + ), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_step_user_error_handling( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_immich: Mock, + exception: Exception, + error: str, +) -> None: + """Test a user initiated config flow with errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_immich.users.async_get_my_user.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_immich.users.async_get_my_user.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_step_user_invalid_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock +) -> None: + """Test a user initiated config flow with errors.""" + 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"], + {**MOCK_USER_DATA, CONF_URL: "hts://invalid"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_URL: "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_already_configured( + hass: HomeAssistant, mock_immich: Mock, 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} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + ImmichUnauthorizedError( + { + "message": "Invalid API key", + "error": "Unauthenticated", + "statusCode": 401, + "correlationId": "abcdefg", + } + ), + "invalid_auth", + ), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_error_handling( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauthentication flow with errors.""" + 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_immich.users.async_get_my_user.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_immich.users.async_get_my_user.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow_mismatch( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication flow with mis-matching unique id.""" + 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_immich.users.async_get_my_user.return_value.user_id = "other_user_id" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/immich/test_diagnostics.py b/tests/components/immich/test_diagnostics.py new file mode 100644 index 00000000000..67b4bfa01d8 --- /dev/null +++ b/tests/components/immich/test_diagnostics.py @@ -0,0 +1,33 @@ +"""Tests for the Immich integration.""" + +from __future__ import annotations + +from unittest.mock import Mock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +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_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id")) diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py new file mode 100644 index 00000000000..5b396a780cc --- /dev/null +++ b/tests/components/immich/test_media_source.py @@ -0,0 +1,409 @@ +"""Tests for Immich media source.""" + +from pathlib import Path +import tempfile +from unittest.mock import Mock, patch + +from aiohttp import web +from aioimmich.exceptions import ImmichError +import pytest + +from homeassistant.components.immich.const import DOMAIN +from homeassistant.components.immich.media_source import ( + ImmichMediaSource, + ImmichMediaView, + async_get_media_source, +) +from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_source import ( + BrowseError, + BrowseMedia, + MediaSourceItem, + Unresolvable, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockRequest, MockStreamReaderChunked + +from . import setup_integration +from .const import MOCK_ALBUM_WITHOUT_ASSETS + +from tests.common import MockConfigEntry + + +async def test_get_media_source(hass: HomeAssistant) -> None: + """Test the async_get_media_source.""" + assert await async_setup_component(hass, "media_source", {}) + + source = await async_get_media_source(hass) + assert isinstance(source, ImmichMediaSource) + assert source.domain == DOMAIN + + +@pytest.mark.parametrize( + ("identifier", "exception_msg"), + [ + ("unique_id", "Could not resolve identifier that has no mime-type"), + ( + "unique_id|albums|album_id", + "Could not resolve identifier that has no mime-type", + ), + ( + "unique_id|albums|album_id|asset_id|filename", + "Could not parse identifier", + ), + ], +) +async def test_resolve_media_bad_identifier( + hass: HomeAssistant, identifier: str, exception_msg: str +) -> None: + """Test resolve_media with bad identifiers.""" + assert await async_setup_component(hass, "media_source", {}) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, identifier, None) + with pytest.raises(Unresolvable, match=exception_msg): + await source.async_resolve_media(item) + + +@pytest.mark.parametrize( + ("identifier", "url", "mime_type"), + [ + ( + "unique_id|albums|album_id|asset_id|filename.jpg|image/jpeg", + "/immich/unique_id/asset_id/fullsize/image/jpeg", + "image/jpeg", + ), + ( + "unique_id|albums|album_id|asset_id|filename.png|image/png", + "/immich/unique_id/asset_id/fullsize/image/png", + "image/png", + ), + ( + "unique_id|albums|album_id|asset_id|filename.mp4|video/mp4", + "/immich/unique_id/asset_id/fullsize/video/mp4", + "video/mp4", + ), + ], +) +async def test_resolve_media_success( + hass: HomeAssistant, identifier: str, url: str, mime_type: str +) -> None: + """Test successful resolving an item.""" + assert await async_setup_component(hass, "media_source", {}) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, identifier, None) + result = await source.async_resolve_media(item) + + assert result.url == url + assert result.mime_type == mime_type + + +async def test_browse_media_unconfigured(hass: HomeAssistant) -> None: + """Test browse_media without any devices being configured.""" + assert await async_setup_component(hass, "media_source", {}) + + source = await async_get_media_source(hass) + item = MediaSourceItem( + hass, DOMAIN, "unique_id/albums/album_id/asset_id/filename.png", None + ) + with pytest.raises(BrowseError, match="Immich is not configured"): + await source.async_browse_media(item) + + +async def test_browse_media_get_root( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media returning root media sources.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + # get root + item = MediaSourceItem(hass, DOMAIN, "", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + media_file = result.children[0] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "Someone" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e" + ) + + # get collections + item = MediaSourceItem(hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + media_file = result.children[0] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "albums" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums" + ) + + +async def test_browse_media_get_albums( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + item = MediaSourceItem( + hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums", None + ) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + media_file = result.children[0] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "My Album" + assert media_file.media_content_id == ( + "media-source://immich/" + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6" + ) + + +async def test_browse_media_get_albums_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media with unknown album.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + # exception in get_albums() + mock_immich.albums.async_get_all_albums.side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + + source = await async_get_media_source(hass) + + item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}|albums", None) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +async def test_browse_media_get_album_items_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + # unknown album + mock_immich.albums.async_get_album_info.return_value = MOCK_ALBUM_WITHOUT_ASSETS + item = MediaSourceItem( + hass, + DOMAIN, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + # exception in async_get_album_info() + mock_immich.albums.async_get_album_info.side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + item = MediaSourceItem( + hass, + DOMAIN, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +async def test_browse_media_get_album_items( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + item = MediaSourceItem( + hass, + DOMAIN, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 2 + media_file = result.children[0] + assert isinstance(media_file, BrowseMedia) + assert media_file.identifier == ( + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4|filename.jpg|image/jpeg" + ) + assert media_file.title == "filename.jpg" + assert media_file.media_class == MediaClass.IMAGE + assert media_file.media_content_type == "image/jpeg" + assert media_file.can_play is False + assert not media_file.can_expand + assert media_file.thumbnail == ( + "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg" + ) + + media_file = result.children[1] + assert isinstance(media_file, BrowseMedia) + assert media_file.identifier == ( + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b|filename.mp4|video/mp4" + ) + assert media_file.title == "filename.mp4" + assert media_file.media_class == MediaClass.VIDEO + assert media_file.media_content_type == "video/mp4" + assert media_file.can_play is True + assert not media_file.can_expand + assert media_file.thumbnail == ( + "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail/image/jpeg" + ) + + +async def test_media_view( + hass: HomeAssistant, + tmp_path: Path, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SynologyDsmMediaView returning albums.""" + view = ImmichMediaView(hass) + request = MockRequest(b"", DOMAIN) + + # immich noch configured + with pytest.raises(web.HTTPNotFound): + await view.get(request, "", "") + + # setup immich + assert await async_setup_component(hass, "media_source", {}) + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + # wrong url (without mime type) + with pytest.raises(web.HTTPNotFound): + await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail", + ) + + # exception in async_view_asset() + mock_immich.assets.async_view_asset.side_effect = ImmichError( + { + "message": "Not found or no asset.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + with pytest.raises(web.HTTPNotFound): + await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg", + ) + + # exception in async_play_video_stream() + mock_immich.assets.async_play_video_stream.side_effect = ImmichError( + { + "message": "Not found or no asset.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + with pytest.raises(web.HTTPNotFound): + await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/fullsize/video/mp4", + ) + + # success + mock_immich.assets.async_view_asset.side_effect = None + mock_immich.assets.async_view_asset.return_value = b"xxxx" + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg", + ) + assert isinstance(result, web.Response) + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/fullsize/image/jpeg", + ) + assert isinstance(result, web.Response) + + mock_immich.assets.async_play_video_stream.side_effect = None + mock_immich.assets.async_play_video_stream.return_value = MockStreamReaderChunked( + b"xxxx" + ) + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/fullsize/video/mp4", + ) + assert isinstance(result, web.StreamResponse) diff --git a/tests/components/immich/test_sensor.py b/tests/components/immich/test_sensor.py new file mode 100644 index 00000000000..510999f584e --- /dev/null +++ b/tests/components/immich/test_sensor.py @@ -0,0 +1,45 @@ +"""Test the Immich sensor platform.""" + +from unittest.mock import Mock, 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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Immich sensor platform.""" + + with patch("homeassistant.components.immich.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_admin_sensors( + hass: HomeAssistant, + mock_non_admin_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the integration doesn't create admin sensors if not admin.""" + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.mock_title_photos_count") is None + assert hass.states.get("sensor.mock_title_videos_count") is None + assert hass.states.get("sensor.mock_title_disk_used_by_photos") is None + assert hass.states.get("sensor.mock_title_disk_used_by_videos") is None diff --git a/tests/components/incomfort/snapshots/test_binary_sensor.ambr b/tests/components/incomfort/snapshots/test_binary_sensor.ambr index 518ea230705..cb938e5b1b7 100644 --- a/tests/components/incomfort/snapshots/test_binary_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Burner', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_burning', 'unique_id': 'c0ffeec0ffee_is_burning', @@ -75,6 +76,7 @@ 'original_name': 'Fault', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fault', 'unique_id': 'c0ffeec0ffee_failed', @@ -124,6 +126,7 @@ 'original_name': 'Hot water tap', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_tapping', 'unique_id': 'c0ffeec0ffee_is_tapping', @@ -172,6 +175,7 @@ 'original_name': 'Pump', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_pumping', 'unique_id': 'c0ffeec0ffee_is_pumping', @@ -220,6 +224,7 @@ 'original_name': 'Burner', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_burning', 'unique_id': 'c0ffeec0ffee_is_burning', @@ -268,6 +273,7 @@ 'original_name': 'Fault', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fault', 'unique_id': 'c0ffeec0ffee_failed', @@ -317,6 +323,7 @@ 'original_name': 'Hot water tap', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_tapping', 'unique_id': 'c0ffeec0ffee_is_tapping', @@ -365,6 +372,7 @@ 'original_name': 'Pump', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_pumping', 'unique_id': 'c0ffeec0ffee_is_pumping', @@ -413,6 +421,7 @@ 'original_name': 'Burner', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_burning', 'unique_id': 'c0ffeec0ffee_is_burning', @@ -461,6 +470,7 @@ 'original_name': 'Fault', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fault', 'unique_id': 'c0ffeec0ffee_failed', @@ -510,6 +520,7 @@ 'original_name': 'Hot water tap', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_tapping', 'unique_id': 'c0ffeec0ffee_is_tapping', @@ -558,6 +569,7 @@ 'original_name': 'Pump', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_pumping', 'unique_id': 'c0ffeec0ffee_is_pumping', @@ -606,6 +618,7 @@ 'original_name': 'Burner', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_burning', 'unique_id': 'c0ffeec0ffee_is_burning', @@ -654,6 +667,7 @@ 'original_name': 'Fault', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fault', 'unique_id': 'c0ffeec0ffee_failed', @@ -703,6 +717,7 @@ 'original_name': 'Hot water tap', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_tapping', 'unique_id': 'c0ffeec0ffee_is_tapping', @@ -751,6 +766,7 @@ 'original_name': 'Pump', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_pumping', 'unique_id': 'c0ffeec0ffee_is_pumping', @@ -799,6 +815,7 @@ 'original_name': 'Burner', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_burning', 'unique_id': 'c0ffeec0ffee_is_burning', @@ -847,6 +864,7 @@ 'original_name': 'Fault', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fault', 'unique_id': 'c0ffeec0ffee_failed', @@ -896,6 +914,7 @@ 'original_name': 'Hot water tap', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_tapping', 'unique_id': 'c0ffeec0ffee_is_tapping', @@ -944,6 +963,7 @@ 'original_name': 'Pump', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_pumping', 'unique_id': 'c0ffeec0ffee_is_pumping', diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index d435bac81eb..dd5c9ca00d7 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -33,6 +33,7 @@ 'original_name': None, 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'c0ffeec0ffee_1', @@ -100,6 +101,7 @@ 'original_name': None, 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'c0ffeec0ffee_1', @@ -167,6 +169,7 @@ 'original_name': None, 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'c0ffeec0ffee_1', @@ -234,6 +237,7 @@ 'original_name': None, 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'c0ffeec0ffee_1', diff --git a/tests/components/incomfort/snapshots/test_sensor.ambr b/tests/components/incomfort/snapshots/test_sensor.ambr index 294a6094164..80dd945d7bf 100644 --- a/tests/components/incomfort/snapshots/test_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pressure', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'c0ffeec0ffee_cv_pressure', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Tap temperature', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tap_temperature', 'unique_id': 'c0ffeec0ffee_tap_temp', @@ -128,12 +136,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'c0ffeec0ffee_cv_temp', diff --git a/tests/components/incomfort/snapshots/test_water_heater.ambr b/tests/components/incomfort/snapshots/test_water_heater.ambr index d3fc2b057fc..dd55793290f 100644 --- a/tests/components/incomfort/snapshots/test_water_heater.ambr +++ b/tests/components/incomfort/snapshots/test_water_heater.ambr @@ -30,6 +30,7 @@ 'original_name': None, 'platform': 'incomfort', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boiler', 'unique_id': 'c0ffeec0ffee', diff --git a/tests/components/incomfort/test_binary_sensor.py b/tests/components/incomfort/test_binary_sensor.py index e90cc3ac391..e0716324de7 100644 --- a/tests/components/incomfort/test_binary_sensor.py +++ b/tests/components/incomfort/test_binary_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch from incomfortclient import FaultCode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index dbcf14e3bd7..a4c97d88e34 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import climate from homeassistant.components.incomfort.coordinator import InComfortData diff --git a/tests/components/incomfort/test_sensor.py b/tests/components/incomfort/test_sensor.py index df0db39a56c..78e7a52362b 100644 --- a/tests/components/incomfort/test_sensor.py +++ b/tests/components/incomfort/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/tests/components/incomfort/test_water_heater.py b/tests/components/incomfort/test_water_heater.py index 082aecf6d49..35edb134ac9 100644 --- a/tests/components/incomfort/test_water_heater.py +++ b/tests/components/incomfort/test_water_heater.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index f798fee292c..7228f64448b 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -103,3 +103,13 @@ IAM_T1_SERVICE_INFO = _make_bluetooth_service_info( service_data={}, source="local", ) + +IBS_P02B_SERVICE_INFO = _make_bluetooth_service_info( + name="IBS-P02B", + manufacturer_data={9289: bytes.fromhex("111800656e0100005f00000100000000")}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + address="49:24:11:18:00:65", + rssi=-60, + service_data={}, + source="local", +) diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 1feb5f5b02c..2a95714df4b 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.util import dt as dt_util from . import ( IAM_T1_SERVICE_INFO, + IBS_P02B_SERVICE_INFO, SPS_PASSIVE_SERVICE_INFO, SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO, @@ -256,3 +257,40 @@ async def test_notify_sensor(hass: HomeAssistant) -> None: saved_device_data_changed_callback({"temp_unit": "C"}) assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "C"} + + +async def test_ibs_p02b_sensors(hass: HomeAssistant) -> None: + """Test setting up creates the sensors for an IBS-P02B.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="49:24:11:18:00:65", + ) + 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 + inject_bluetooth_service_info(hass, IBS_P02B_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + + temp_sensor = hass.states.get("sensor.ibs_p02b_0065_battery") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "95" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-P02B 0065 Battery" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + temp_sensor = hass.states.get("sensor.ibs_p02b_0065_temperature") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "36.6" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-P02B 0065 Temperature" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + # Make sure we remember the device type + # in case the name is corrupted later + assert entry.data[CONF_DEVICE_TYPE] == "IBS-P02B" + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 9fee54f4500..0ce3297a2ff 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -1,14 +1,97 @@ """Test the Integration - Riemann sum integral integration.""" +from unittest.mock import patch + import pytest +from homeassistant.components import integration +from homeassistant.components.integration.config_flow import ConfigFlowHandler from homeassistant.components.integration.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def integration_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create an integration config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": sensor_entity_entry.entity_id, + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + @pytest.mark.parametrize("platform", ["sensor"]) async def test_setup_and_remove_config_entry( hass: HomeAssistant, @@ -209,3 +292,194 @@ async def test_device_cleaning( integration_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the integration config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the integration config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the integration config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert integration_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the integration config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert integration_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the integration config entry is updated with the new entity ID + assert integration_config_entry.options["source"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] diff --git a/tests/components/intellifire/snapshots/test_binary_sensor.ambr b/tests/components/intellifire/snapshots/test_binary_sensor.ambr index c2ed8ff17b0..2c33012488b 100644 --- a/tests/components/intellifire/snapshots/test_binary_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Accessory error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'accessory_error', 'unique_id': 'error_accessory_mock_serial', @@ -76,6 +77,7 @@ 'original_name': 'Cloud connectivity', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_connectivity', 'unique_id': 'cloud_connectivity_mock_serial', @@ -125,6 +127,7 @@ 'original_name': 'Disabled error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disabled_error', 'unique_id': 'error_disabled_mock_serial', @@ -174,6 +177,7 @@ 'original_name': 'ECM offline error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ecm_offline_error', 'unique_id': 'error_ecm_offline_mock_serial', @@ -223,6 +227,7 @@ 'original_name': 'Fan delay error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan_delay_error', 'unique_id': 'error_fan_delay_mock_serial', @@ -272,6 +277,7 @@ 'original_name': 'Fan error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan_error', 'unique_id': 'error_fan_mock_serial', @@ -321,6 +327,7 @@ 'original_name': 'Flame', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flame', 'unique_id': 'on_off_mock_serial', @@ -369,6 +376,7 @@ 'original_name': 'Flame error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flame_error', 'unique_id': 'error_flame_mock_serial', @@ -418,6 +426,7 @@ 'original_name': 'Lights error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lights_error', 'unique_id': 'error_lights_mock_serial', @@ -467,6 +476,7 @@ 'original_name': 'Local connectivity', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'local_connectivity', 'unique_id': 'local_connectivity_mock_serial', @@ -516,6 +526,7 @@ 'original_name': 'Maintenance error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'maintenance_error', 'unique_id': 'error_maintenance_mock_serial', @@ -565,6 +576,7 @@ 'original_name': 'Offline error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'offline_error', 'unique_id': 'error_offline_mock_serial', @@ -614,6 +626,7 @@ 'original_name': 'Pilot flame error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pilot_flame_error', 'unique_id': 'error_pilot_flame_mock_serial', @@ -663,6 +676,7 @@ 'original_name': 'Pilot light on', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pilot_light_on', 'unique_id': 'pilot_light_on_mock_serial', @@ -711,6 +725,7 @@ 'original_name': 'Soft lock out error', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'soft_lock_out_error', 'unique_id': 'error_soft_lock_out_mock_serial', @@ -760,6 +775,7 @@ 'original_name': 'Thermostat on', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermostat_on', 'unique_id': 'thermostat_on_mock_serial', @@ -808,6 +824,7 @@ 'original_name': 'Timer on', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'timer_on', 'unique_id': 'timer_on_mock_serial', diff --git a/tests/components/intellifire/snapshots/test_climate.ambr b/tests/components/intellifire/snapshots/test_climate.ambr index d0744424cff..e13d9c6c0b4 100644 --- a/tests/components/intellifire/snapshots/test_climate.ambr +++ b/tests/components/intellifire/snapshots/test_climate.ambr @@ -35,6 +35,7 @@ 'original_name': 'Thermostat', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'climate_mock_serial', diff --git a/tests/components/intellifire/snapshots/test_sensor.ambr b/tests/components/intellifire/snapshots/test_sensor.ambr index 3826b75a417..a641db96ffc 100644 --- a/tests/components/intellifire/snapshots/test_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Connection quality', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connection_quality', 'unique_id': 'connection_quality_mock_serial', @@ -75,6 +76,7 @@ 'original_name': 'Downtime', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'downtime', 'unique_id': 'downtime_mock_serial', @@ -124,6 +126,7 @@ 'original_name': 'ECM latency', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ecm_latency', 'unique_id': 'ecm_latency_mock_serial', @@ -174,6 +177,7 @@ 'original_name': 'Fan speed', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan_speed', 'unique_id': 'fan_speed_mock_serial', @@ -225,6 +229,7 @@ 'original_name': 'Flame height', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flame_height', 'unique_id': 'flame_height_mock_serial', @@ -274,6 +279,7 @@ 'original_name': 'IP address', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ipv4_address', 'unique_id': 'ipv4_address_mock_serial', @@ -318,12 +324,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Target temperature', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'target_temp', 'unique_id': 'target_temp_mock_serial', @@ -371,12 +381,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'temperature_mock_serial', @@ -430,6 +444,7 @@ 'original_name': 'Timer end', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'timer_end_timestamp', 'unique_id': 'timer_end_timestamp_mock_serial', @@ -480,6 +495,7 @@ 'original_name': 'Uptime', 'platform': 'intellifire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uptime', 'unique_id': 'uptime_mock_serial', diff --git a/tests/components/intellifire/test_binary_sensor.py b/tests/components/intellifire/test_binary_sensor.py index a40f92b84d5..d8bce78263d 100644 --- a/tests/components/intellifire/test_binary_sensor.py +++ b/tests/components/intellifire/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/intellifire/test_climate.py b/tests/components/intellifire/test_climate.py index da1b2864791..6b4ad01f9d6 100644 --- a/tests/components/intellifire/test_climate.py +++ b/tests/components/intellifire/test_climate.py @@ -4,7 +4,7 @@ from unittest.mock import patch from freezegun import freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/intellifire/test_sensor.py b/tests/components/intellifire/test_sensor.py index 96e344d77fc..9b5d25c679a 100644 --- a/tests/components/intellifire/test_sensor.py +++ b/tests/components/intellifire/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun import freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/iometer/snapshots/test_binary_sensor.ambr b/tests/components/iometer/snapshots/test_binary_sensor.ambr index 38aab735a14..7e64f56a1fc 100644 --- a/tests/components/iometer/snapshots/test_binary_sensor.ambr +++ b/tests/components/iometer/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Core attachment status', 'platform': 'iometer', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'attachment_status', 'unique_id': '01JQ6G5395176MAAWKAAPEZHV6_attachment_status', @@ -75,6 +76,7 @@ 'original_name': 'Core/Bridge connection status', 'platform': 'iometer', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connection_status', 'unique_id': '01JQ6G5395176MAAWKAAPEZHV6_connection_status', diff --git a/tests/components/iotawatt/conftest.py b/tests/components/iotawatt/conftest.py index 9380154b53e..3b30783494e 100644 --- a/tests/components/iotawatt/conftest.py +++ b/tests/components/iotawatt/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components.iotawatt import DOMAIN +from homeassistant.components.iotawatt.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index 16913d340f0..058a5d35cd0 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -78,6 +78,7 @@ 'original_name': None, 'platform': 'iotty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'TestLS', diff --git a/tests/components/ipma/conftest.py b/tests/components/ipma/conftest.py index 8f2a017dcb8..caf49f594fb 100644 --- a/tests/components/ipma/conftest.py +++ b/tests/components/ipma/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.ipma import DOMAIN +from homeassistant.components.ipma.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant diff --git a/tests/components/ipma/test_init.py b/tests/components/ipma/test_init.py index 7967b97dd23..4a0314a0d9a 100644 --- a/tests/components/ipma/test_init.py +++ b/tests/components/ipma/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyipma import IPMAException -from homeassistant.components.ipma import DOMAIN +from homeassistant.components.ipma.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE from homeassistant.core import HomeAssistant diff --git a/tests/components/ipp/snapshots/test_sensor.ambr b/tests/components/ipp/snapshots/test_sensor.ambr index f8e0578a6b9..5a9669c1afb 100644 --- a/tests/components/ipp/snapshots/test_sensor.ambr +++ b/tests/components/ipp/snapshots/test_sensor.ambr @@ -33,6 +33,7 @@ 'original_name': None, 'platform': 'ipp', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'printer', 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_printer', @@ -95,6 +96,7 @@ 'original_name': 'Black ink', 'platform': 'ipp', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'marker', 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_marker_0', @@ -149,6 +151,7 @@ 'original_name': 'Cyan ink', 'platform': 'ipp', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'marker', 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_marker_1', @@ -203,6 +206,7 @@ 'original_name': 'Magenta ink', 'platform': 'ipp', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'marker', 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_marker_2', @@ -257,6 +261,7 @@ 'original_name': 'Photo black ink', 'platform': 'ipp', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'marker', 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_marker_3', @@ -309,6 +314,7 @@ 'original_name': 'Uptime', 'platform': 'ipp', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uptime', 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_uptime', @@ -359,6 +365,7 @@ 'original_name': 'Yellow ink', 'platform': 'ipp', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'marker', 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_marker_4', diff --git a/tests/components/ipp/test_diagnostics.py b/tests/components/ipp/test_diagnostics.py index d78f066d788..3bd1fbc2e3e 100644 --- a/tests/components/ipp/test_diagnostics.py +++ b/tests/components/ipp/test_diagnostics.py @@ -1,7 +1,7 @@ """Tests for the diagnostics data provided by the Internet Printing Protocol (IPP) integration.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/iqvia/test_config_flow.py b/tests/components/iqvia/test_config_flow.py index 22f473a3fb5..9a973ebe49c 100644 --- a/tests/components/iqvia/test_config_flow.py +++ b/tests/components/iqvia/test_config_flow.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN +from homeassistant.components.iqvia.const import CONF_ZIP_CODE, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/iqvia/test_diagnostics.py b/tests/components/iqvia/test_diagnostics.py index 9d5639c311c..dc3d0cb8557 100644 --- a/tests/components/iqvia/test_diagnostics.py +++ b/tests/components/iqvia/test_diagnostics.py @@ -1,6 +1,6 @@ """Test IQVIA diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/iron_os/snapshots/test_binary_sensor.ambr b/tests/components/iron_os/snapshots/test_binary_sensor.ambr index c36c1cc42ff..5d866d38786 100644 --- a/tests/components/iron_os/snapshots/test_binary_sensor.ambr +++ b/tests/components/iron_os/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Soldering tip', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_connected', diff --git a/tests/components/iron_os/snapshots/test_button.ambr b/tests/components/iron_os/snapshots/test_button.ambr index c9ff9181515..329940d5ca1 100644 --- a/tests/components/iron_os/snapshots/test_button.ambr +++ b/tests/components/iron_os/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Restore default settings', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_settings_reset', @@ -74,6 +75,7 @@ 'original_name': 'Save settings', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_settings_save', diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index b2ec7a70a92..37d8b1f4819 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Boost temperature', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_boost_temp', @@ -90,6 +91,7 @@ 'original_name': 'Calibration offset', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_calibration_offset', @@ -147,6 +149,7 @@ 'original_name': 'Display brightness', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_display_brightness', @@ -203,6 +206,7 @@ 'original_name': 'Hall effect sensitivity', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_hall_sensitivity', @@ -259,6 +263,7 @@ 'original_name': 'Hall sensor sleep timeout', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_hall_effect_sleep_time', @@ -316,6 +321,7 @@ 'original_name': 'Keep-awake pulse delay', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_keep_awake_pulse_delay', @@ -373,6 +379,7 @@ 'original_name': 'Keep-awake pulse duration', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_keep_awake_pulse_duration', @@ -430,6 +437,7 @@ 'original_name': 'Keep-awake pulse intensity', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_keep_awake_pulse_power', @@ -487,6 +495,7 @@ 'original_name': 'Long-press temperature step', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_temp_increment_long', @@ -544,6 +553,7 @@ 'original_name': 'Min. voltage per cell', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_min_voltage_per_cell', @@ -601,6 +611,7 @@ 'original_name': 'Motion sensitivity', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_accel_sensitivity', @@ -657,6 +668,7 @@ 'original_name': 'Power Delivery timeout', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_pd_timeout', @@ -715,6 +727,7 @@ 'original_name': 'Power limit', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_power_limit', @@ -772,6 +785,7 @@ 'original_name': 'Quick Charge voltage', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_qc_max_voltage', @@ -830,6 +844,7 @@ 'original_name': 'Setpoint temperature', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_setpoint_temperature', @@ -888,6 +903,7 @@ 'original_name': 'Short-press temperature step', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_temp_increment_short', @@ -945,6 +961,7 @@ 'original_name': 'Shutdown timeout', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_shutdown_timeout', @@ -1003,6 +1020,7 @@ 'original_name': 'Sleep temperature', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_sleep_temperature', @@ -1061,6 +1079,7 @@ 'original_name': 'Sleep timeout', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_sleep_timeout', @@ -1118,6 +1137,7 @@ 'original_name': 'Voltage divider', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_voltage_div', diff --git a/tests/components/iron_os/snapshots/test_select.ambr b/tests/components/iron_os/snapshots/test_select.ambr index 540cab234a5..41696371411 100644 --- a/tests/components/iron_os/snapshots/test_select.ambr +++ b/tests/components/iron_os/snapshots/test_select.ambr @@ -34,6 +34,7 @@ 'original_name': 'Animation speed', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_animation_speed', @@ -97,6 +98,7 @@ 'original_name': 'Boot logo duration', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_logo_duration', @@ -159,6 +161,7 @@ 'original_name': 'Button locking mode', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_locking_mode', @@ -217,6 +220,7 @@ 'original_name': 'Display orientation mode', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_orientation_mode', @@ -275,6 +279,7 @@ 'original_name': 'Power Delivery 3.1 EPR', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_usb_pd_mode', @@ -335,6 +340,7 @@ 'original_name': 'Power source', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_min_dc_voltage_cells', @@ -394,6 +400,7 @@ 'original_name': 'Scrolling speed', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_desc_scroll_speed', @@ -452,6 +459,7 @@ 'original_name': 'Soldering tip type', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_type', @@ -512,6 +520,7 @@ 'original_name': 'Start-up behavior', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_autostart_mode', @@ -570,6 +579,7 @@ 'original_name': 'Temperature display unit', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_temp_unit', diff --git a/tests/components/iron_os/snapshots/test_sensor.ambr b/tests/components/iron_os/snapshots/test_sensor.ambr index 6a30aa6632b..39dda49d313 100644 --- a/tests/components/iron_os/snapshots/test_sensor.ambr +++ b/tests/components/iron_os/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DC input voltage', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_voltage', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Estimated power', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_estimated_power', @@ -133,6 +141,7 @@ 'original_name': 'Hall effect strength', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_hall_sensor', @@ -177,12 +186,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Handle temperature', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_handle_temperature', @@ -229,12 +242,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Last movement time', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_movement_time', @@ -279,12 +296,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Max tip temperature', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_max_tip_temp_ability', @@ -352,6 +373,7 @@ 'original_name': 'Operating mode', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_operating_mode', @@ -422,6 +444,7 @@ 'original_name': 'Power level', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_power_pwm_level', @@ -479,6 +502,7 @@ 'original_name': 'Power source', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_power_source', @@ -538,6 +562,7 @@ 'original_name': 'Raw tip voltage', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_voltage', @@ -590,6 +615,7 @@ 'original_name': 'Tip resistance', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_resistance', @@ -635,12 +661,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Tip temperature', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_live_temperature', @@ -687,12 +717,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Uptime', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_uptime', diff --git a/tests/components/iron_os/snapshots/test_switch.ambr b/tests/components/iron_os/snapshots/test_switch.ambr index a3d28e58d63..ff231c4050f 100644 --- a/tests/components/iron_os/snapshots/test_switch.ambr +++ b/tests/components/iron_os/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Animation loop', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_animation_loop', @@ -74,6 +75,7 @@ 'original_name': 'Calibrate CJC', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_calibrate_cjc', @@ -121,6 +123,7 @@ 'original_name': 'Cool down screen flashing', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_cooling_temp_blink', @@ -168,6 +171,7 @@ 'original_name': 'Detailed idle screen', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_idle_screen_details', @@ -215,6 +219,7 @@ 'original_name': 'Detailed solder screen', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_solder_screen_details', @@ -262,6 +267,7 @@ 'original_name': 'Invert screen', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_display_invert', @@ -309,6 +315,7 @@ 'original_name': 'Swap +/- buttons', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_invert_buttons', diff --git a/tests/components/iron_os/snapshots/test_update.ambr b/tests/components/iron_os/snapshots/test_update.ambr index fcd7196a70c..48d702001a4 100644 --- a/tests/components/iron_os/snapshots/test_update.ambr +++ b/tests/components/iron_os/snapshots/test_update.ambr @@ -30,6 +30,7 @@ 'original_name': 'Firmware', 'platform': 'iron_os', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'c0:ff:ee:c0:ff:ee_firmware', diff --git a/tests/components/israel_rail/snapshots/test_sensor.ambr b/tests/components/israel_rail/snapshots/test_sensor.ambr index 610c2c53e22..e9c9bec80aa 100644 --- a/tests/components/israel_rail/snapshots/test_sensor.ambr +++ b/tests/components/israel_rail/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Departure', 'platform': 'israel_rail', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'departure0', 'unique_id': 'באר יעקב אשקלון_departure', @@ -76,6 +77,7 @@ 'original_name': 'Departure +1', 'platform': 'israel_rail', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'departure1', 'unique_id': 'באר יעקב אשקלון_departure1', @@ -125,6 +127,7 @@ 'original_name': 'Departure +2', 'platform': 'israel_rail', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'departure2', 'unique_id': 'באר יעקב אשקלון_departure2', @@ -174,6 +177,7 @@ 'original_name': 'Platform', 'platform': 'israel_rail', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'platform', 'unique_id': 'באר יעקב אשקלון_platform', @@ -222,6 +226,7 @@ 'original_name': 'Train number', 'platform': 'israel_rail', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'train_number', 'unique_id': 'באר יעקב אשקלון_train_number', @@ -270,6 +275,7 @@ 'original_name': 'Trains', 'platform': 'israel_rail', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'trains', 'unique_id': 'באר יעקב אשקלון_trains', diff --git a/tests/components/israel_rail/test_sensor.py b/tests/components/israel_rail/test_sensor.py index 85b7328742f..08aed2bbc21 100644 --- a/tests/components/israel_rail/test_sensor.py +++ b/tests/components/israel_rail/test_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/ista_ecotrend/snapshots/test_sensor.ambr b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr index 296ce26c7f2..1d6cabcd2fa 100644 --- a/tests/components/ista_ecotrend/snapshots/test_sensor.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr @@ -32,6 +32,7 @@ 'original_name': 'Heating', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_heating', @@ -86,6 +87,7 @@ 'original_name': 'Heating cost', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_heating_cost', @@ -141,6 +143,7 @@ 'original_name': 'Heating energy', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_heating_energy', @@ -196,6 +199,7 @@ 'original_name': 'Hot water', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_hot_water', @@ -251,6 +255,7 @@ 'original_name': 'Hot water cost', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_hot_water_cost', @@ -306,6 +311,7 @@ 'original_name': 'Hot water energy', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_hot_water_energy', @@ -361,6 +367,7 @@ 'original_name': 'Water', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_water', @@ -416,6 +423,7 @@ 'original_name': 'Water cost', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_water_cost', @@ -471,6 +479,7 @@ 'original_name': 'Heating', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_heating', @@ -525,6 +534,7 @@ 'original_name': 'Heating cost', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_heating_cost', @@ -580,6 +590,7 @@ 'original_name': 'Heating energy', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_heating_energy', @@ -635,6 +646,7 @@ 'original_name': 'Hot water', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_hot_water', @@ -690,6 +702,7 @@ 'original_name': 'Hot water cost', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_hot_water_cost', @@ -745,6 +758,7 @@ 'original_name': 'Hot water energy', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_hot_water_energy', @@ -800,6 +814,7 @@ 'original_name': 'Water', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_water', @@ -855,6 +870,7 @@ 'original_name': 'Water cost', 'platform': 'ista_ecotrend', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_water_cost', diff --git a/tests/components/isy994/test_system_health.py b/tests/components/isy994/test_system_health.py index 5f472189513..0a6e4b403b8 100644 --- a/tests/components/isy994/test_system_health.py +++ b/tests/components/isy994/test_system_health.py @@ -6,6 +6,7 @@ from unittest.mock import Mock from aiohttp import ClientError from homeassistant.components.isy994.const import DOMAIN, ISY_URL_POSTFIX +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -30,12 +31,14 @@ async def test_system_health( assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, entry_id=MOCK_ENTRY_ID, data={CONF_HOST: f"http://{MOCK_HOSTNAME}"}, unique_id=MOCK_UUID, - ).add_to_hass(hass) + state=ConfigEntryState.LOADED, + ) + entry.add_to_hass(hass) isy_data = Mock( root=Mock( @@ -46,7 +49,7 @@ async def test_system_health( ), ) ) - hass.data[DOMAIN] = {MOCK_ENTRY_ID: isy_data} + entry.runtime_data = isy_data info = await get_system_health_info(hass, DOMAIN) @@ -70,12 +73,14 @@ async def test_system_health_failed_connect( assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, entry_id=MOCK_ENTRY_ID, data={CONF_HOST: f"http://{MOCK_HOSTNAME}"}, unique_id=MOCK_UUID, - ).add_to_hass(hass) + state=ConfigEntryState.LOADED, + ) + entry.add_to_hass(hass) isy_data = Mock( root=Mock( @@ -86,7 +91,7 @@ async def test_system_health_failed_connect( ), ) ) - hass.data[DOMAIN] = {MOCK_ENTRY_ID: isy_data} + entry.runtime_data = isy_data info = await get_system_health_info(hass, DOMAIN) diff --git a/tests/components/ituran/snapshots/test_device_tracker.ambr b/tests/components/ituran/snapshots/test_device_tracker.ambr index e73f0cfee24..2bd5286f7e4 100644 --- a/tests/components/ituran/snapshots/test_device_tracker.ambr +++ b/tests/components/ituran/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'ituran', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'car', 'unique_id': '12345678-device_tracker', diff --git a/tests/components/ituran/snapshots/test_sensor.ambr b/tests/components/ituran/snapshots/test_sensor.ambr index f96190fdbc2..5278c657a66 100644 --- a/tests/components/ituran/snapshots/test_sensor.ambr +++ b/tests/components/ituran/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Address', 'platform': 'ituran', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'address', 'unique_id': '12345678-address', @@ -77,6 +78,7 @@ 'original_name': 'Battery voltage', 'platform': 'ituran', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_voltage', 'unique_id': '12345678-battery_voltage', @@ -129,6 +131,7 @@ 'original_name': 'Heading', 'platform': 'ituran', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heading', 'unique_id': '12345678-heading', @@ -177,6 +180,7 @@ 'original_name': 'Last update from vehicle', 'platform': 'ituran', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_update_from_vehicle', 'unique_id': '12345678-last_update_from_vehicle', @@ -228,6 +232,7 @@ 'original_name': 'Mileage', 'platform': 'ituran', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': '12345678-mileage', @@ -280,6 +285,7 @@ 'original_name': 'Speed', 'platform': 'ituran', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345678-speed', diff --git a/tests/components/jellyfin/test_diagnostics.py b/tests/components/jellyfin/test_diagnostics.py index bd34e3a8e31..822d8dbc5bb 100644 --- a/tests/components/jellyfin/test_diagnostics.py +++ b/tests/components/jellyfin/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Jellyfin diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/jewish_calendar/conftest.py b/tests/components/jewish_calendar/conftest.py index 5cd7ad34085..568affb9ab6 100644 --- a/tests/components/jewish_calendar/conftest.py +++ b/tests/components/jewish_calendar/conftest.py @@ -49,7 +49,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def location_data(request: pytest.FixtureRequest) -> _LocationData | None: """Return data based on location name.""" - if not hasattr(request, "param"): + if not hasattr(request, "param") or request.param is None: return None return LOCATIONS[request.param] diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3c8acde6e72 --- /dev/null +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -0,0 +1,221 @@ +# serializer version: 1 +# name: test_diagnostics[test_time0-Jerusalem] + dict({ + 'data': dict({ + 'candle_lighting_offset': 40, + 'diaspora': False, + 'havdalah_offset': 0, + 'language': 'en', + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", + }), + }), + 'results': dict({ + 'after_shkia_date': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), + 'after_tzais_date': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), + 'daytime_date': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 40, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", + }), + }), + }), + }), + }), + 'entry_data': dict({ + 'diaspora': False, + 'language': 'en', + 'time_zone': 'Asia/Jerusalem', + }), + }) +# --- +# name: test_diagnostics[test_time0-New York] + dict({ + 'data': dict({ + 'candle_lighting_offset': 18, + 'diaspora': True, + 'havdalah_offset': 0, + 'language': 'en', + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", + }), + }), + 'results': dict({ + 'after_shkia_date': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': True, + 'nusach': 'sephardi', + }), + 'after_tzais_date': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': True, + 'nusach': 'sephardi', + }), + 'daytime_date': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': True, + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", + }), + }), + }), + }), + }), + 'entry_data': dict({ + 'diaspora': True, + 'language': 'en', + 'time_zone': 'America/New_York', + }), + }) +# --- +# name: test_diagnostics[test_time0-None] + dict({ + 'data': dict({ + 'candle_lighting_offset': 18, + 'diaspora': False, + 'havdalah_offset': 0, + 'language': 'en', + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", + }), + }), + 'results': dict({ + 'after_shkia_date': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), + 'after_tzais_date': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), + 'daytime_date': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", + }), + }), + }), + }), + }), + 'entry_data': dict({ + 'language': 'en', + }), + }) +# --- diff --git a/tests/components/jewish_calendar/test_diagnostics.py b/tests/components/jewish_calendar/test_diagnostics.py new file mode 100644 index 00000000000..31d224a756d --- /dev/null +++ b/tests/components/jewish_calendar/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Tests for the diagnostics data provided by the Jewish Calendar integration.""" + +import datetime as dt + +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.parametrize( + ("location_data"), ["Jerusalem", "New York", None], indirect=True +) +@pytest.mark.parametrize("test_time", [dt.datetime(2025, 5, 19)], indirect=True) +@pytest.mark.usefixtures("setup_at_time") +async def test_diagnostics( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics with different locations.""" + diagnostics_data = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + + assert diagnostics_data == snapshot diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index d38d20ab4d6..38a3dd12206 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime as dt from typing import Any +from freezegun.api import FrozenDateTimeFactory from hdate.holidays import HolidayDatabase from hdate.parasha import Parasha import pytest @@ -14,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 MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize("language", ["en", "he"]) @@ -59,7 +60,6 @@ TEST_PARAMS = [ "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(), @@ -77,7 +77,6 @@ TEST_PARAMS = [ "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(), @@ -94,21 +93,20 @@ TEST_PARAMS = [ "state": "נצבים", "attr": { "device_class": "enum", - "friendly_name": "Jewish Calendar Parshat Hashavua", - "icon": "mdi:book-open-variant", - "options": list(Parasha), + "friendly_name": "Jewish Calendar Weekly Torah portion", + "options": [str(p) for p in Parasha], }, }, "he", - "parshat_hashavua", - id="torah_reading", + "weekly_torah_portion", + id="torah_portion", ), pytest.param( "New York", dt(2018, 9, 8), {"state": dt(2018, 9, 8, 19, 47)}, "he", - "t_set_hakochavim", + "nightfall_t_set_hakochavim", id="first_stars_ny", ), pytest.param( @@ -116,7 +114,7 @@ TEST_PARAMS = [ dt(2018, 9, 8), {"state": dt(2018, 9, 8, 19, 21)}, "he", - "t_set_hakochavim", + "nightfall_t_set_hakochavim", id="first_stars_jerusalem", ), pytest.param( @@ -124,8 +122,8 @@ TEST_PARAMS = [ dt(2018, 10, 14), {"state": "לך לך"}, "he", - "parshat_hashavua", - id="torah_reading_weekday", + "weekly_torah_portion", + id="torah_portion_weekday", ), pytest.param( "Jerusalem", @@ -144,7 +142,6 @@ TEST_PARAMS = [ "hebrew_year": "5779", "hebrew_month_name": "מרחשוון", "hebrew_day": "6", - "icon": "mdi:star-david", "friendly_name": "Jewish Calendar Date", }, }, @@ -185,8 +182,8 @@ SHABBAT_PARAMS = [ "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": "כי תבוא", + "en_weekly_torah_portion": "Ki Tavo", + "he_weekly_torah_portion": "כי תבוא", }, None, id="currently_first_shabbat", @@ -199,8 +196,8 @@ SHABBAT_PARAMS = [ "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": "כי תבוא", + "en_weekly_torah_portion": "Ki Tavo", + "he_weekly_torah_portion": "כי תבוא", }, 50, # Havdalah offset id="currently_first_shabbat_with_havdalah_offset", @@ -213,8 +210,8 @@ SHABBAT_PARAMS = [ "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": "כי תבוא", + "en_weekly_torah_portion": "Ki Tavo", + "he_weekly_torah_portion": "כי תבוא", }, None, id="currently_first_shabbat_bein_hashmashot_lagging_date", @@ -227,8 +224,8 @@ SHABBAT_PARAMS = [ "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": "נצבים", + "en_weekly_torah_portion": "Nitzavim", + "he_weekly_torah_portion": "נצבים", }, None, id="after_first_shabbat", @@ -241,8 +238,8 @@ SHABBAT_PARAMS = [ "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": "נצבים", + "en_weekly_torah_portion": "Nitzavim", + "he_weekly_torah_portion": "נצבים", }, None, id="friday_upcoming_shabbat", @@ -255,8 +252,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Vayeilech", + "he_weekly_torah_portion": "וילך", "en_holiday": "Erev Rosh Hashana", "he_holiday": "ערב ראש השנה", }, @@ -271,8 +268,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Vayeilech", + "he_weekly_torah_portion": "וילך", "en_holiday": "Rosh Hashana I", "he_holiday": "א' ראש השנה", }, @@ -287,8 +284,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Vayeilech", + "he_weekly_torah_portion": "וילך", "en_holiday": "Rosh Hashana II", "he_holiday": "ב' ראש השנה", }, @@ -303,8 +300,8 @@ SHABBAT_PARAMS = [ "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", + "en_weekly_torah_portion": "none", + "he_weekly_torah_portion": "none", }, None, id="currently_shabbat_chol_hamoed", @@ -317,8 +314,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", "en_holiday": "Hoshana Raba", "he_holiday": "הושענא רבה", }, @@ -333,8 +330,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", "en_holiday": "Shmini Atzeret", "he_holiday": "שמיני עצרת", }, @@ -349,8 +346,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", "en_holiday": "Simchat Torah", "he_holiday": "שמחת תורה", }, @@ -365,8 +362,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", "en_holiday": "Hoshana Raba", "he_holiday": "הושענא רבה", }, @@ -381,8 +378,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", "en_holiday": "Shmini Atzeret, Simchat Torah", "he_holiday": "שמיני עצרת, שמחת תורה", }, @@ -397,8 +394,8 @@ SHABBAT_PARAMS = [ "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": "בראשית", + "en_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", }, None, id="after_one_day_yom_tov_in_israel", @@ -411,8 +408,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Bamidbar", + "he_weekly_torah_portion": "במדבר", "en_holiday": "Erev Shavuot", "he_holiday": "ערב שבועות", }, @@ -427,8 +424,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Nasso", + "he_weekly_torah_portion": "נשא", "en_holiday": "Shavuot", "he_holiday": "שבועות", }, @@ -443,8 +440,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Ha'Azinu", + "he_weekly_torah_portion": "האזינו", "en_holiday": "Rosh Hashana I", "he_holiday": "א' ראש השנה", }, @@ -459,8 +456,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Ha'Azinu", + "he_weekly_torah_portion": "האזינו", "en_holiday": "Rosh Hashana II", "he_holiday": "ב' ראש השנה", }, @@ -475,8 +472,8 @@ SHABBAT_PARAMS = [ "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_weekly_torah_portion": "Ha'Azinu", + "he_weekly_torah_portion": "האזינו", "en_holiday": "", "he_holiday": "", }, @@ -546,6 +543,34 @@ async def test_dafyomi_sensor(hass: HomeAssistant, results: str) -> None: assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == results +@pytest.mark.parametrize( + ("test_time", "results"), + [ + ( + dt(2025, 6, 10, 17), + { + "initial_state": "14 Sivan 5785", + "move_to": dt(2025, 6, 10, 23, 0), + "new_state": "15 Sivan 5785", + }, + ), + ], + indirect=True, +) +@pytest.mark.usefixtures("setup_at_time") +async def test_sensor_does_not_update_on_time_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any] +) -> None: + """Test that the Jewish calendar sensor does not update after time advances (regression test for update bug).""" + sensor_id = "sensor.jewish_calendar_date" + assert hass.states.get(sensor_id).state == results["initial_state"] + + freezer.move_to(results["move_to"]) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sensor_id).state == results["new_state"] + + async def test_no_discovery_info( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/justnimbus/test_config_flow.py b/tests/components/justnimbus/test_config_flow.py index 330b05bf48c..cc3a7a88285 100644 --- a/tests/components/justnimbus/test_config_flow.py +++ b/tests/components/justnimbus/test_config_flow.py @@ -1,6 +1,6 @@ """Test the JustNimbus config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch from justnimbus.exceptions import InvalidClientID, JustNimbusError import pytest @@ -132,7 +132,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with patch( "homeassistant.components.justnimbus.config_flow.justnimbus.JustNimbusClient.get_data", - return_value=True, + return_value=MagicMock(), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/keyboard/__init__.py b/tests/components/keyboard/__init__.py new file mode 100644 index 00000000000..7bc8a91511f --- /dev/null +++ b/tests/components/keyboard/__init__.py @@ -0,0 +1 @@ +"""Keyboard tests.""" diff --git a/tests/components/keyboard/test_init.py b/tests/components/keyboard/test_init.py new file mode 100644 index 00000000000..42a700a3d07 --- /dev/null +++ b/tests/components/keyboard/test_init.py @@ -0,0 +1,29 @@ +"""Keyboard tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", pykeyboard=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + from homeassistant.components.keyboard import ( # pylint:disable=import-outside-toplevel + DOMAIN as KEYBOARD_DOMAIN, + ) + + assert await async_setup_component( + hass, + KEYBOARD_DOMAIN, + {KEYBOARD_DOMAIN: {}}, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{KEYBOARD_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index 5535554017f..9c9f31a2544 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -40,6 +40,7 @@ 'original_name': None, 'platform': 'kitchen_sink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'outlet_1', @@ -153,6 +154,7 @@ 'original_name': None, 'platform': 'kitchen_sink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'outlet_2', diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 933979ee913..02ad346cd58 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -109,7 +109,9 @@ async def test_agents_list_backups( "database_included": False, "date": "1970-01-01T00:00:00Z", "extra_metadata": {}, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": ["media", "share"], "homeassistant_included": True, "homeassistant_version": "2024.12.0", @@ -191,7 +193,9 @@ async def test_agents_upload( "database_included": True, "date": "1970-01-01T00:00:00.000Z", "extra_metadata": {"instance_id": ANY, "with_automatic_settings": False}, + "failed_addons": [], "failed_agent_ids": [], + "failed_folders": [], "folders": ["media", "share"], "homeassistant_included": True, "homeassistant_version": "2024.12.0", diff --git a/tests/components/knocki/__init__.py b/tests/components/knocki/__init__.py index 4ebf6b0dd01..3de1e80d9e4 100644 --- a/tests/components/knocki/__init__.py +++ b/tests/components/knocki/__init__.py @@ -10,3 +10,4 @@ async def setup_integration(hass: HomeAssistant, 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() diff --git a/tests/components/knocki/snapshots/test_event.ambr b/tests/components/knocki/snapshots/test_event.ambr index 65fecd59739..0700e2f48b4 100644 --- a/tests/components/knocki/snapshots/test_event.ambr +++ b/tests/components/knocki/snapshots/test_event.ambr @@ -31,6 +31,7 @@ 'original_name': 'Aaaa', 'platform': 'knocki', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'knocki', 'unique_id': 'KNC1-W-00000214_31', diff --git a/tests/components/knocki/test_config_flow.py b/tests/components/knocki/test_config_flow.py index 188175035da..4affbd2a197 100644 --- a/tests/components/knocki/test_config_flow.py +++ b/tests/components/knocki/test_config_flow.py @@ -6,13 +6,23 @@ from knocki import KnockiConnectionError, KnockiInvalidAuthError import pytest from homeassistant.components.knocki.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from . import setup_integration from tests.common import MockConfigEntry +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.1", + hostname="KNC1-W-00000214", + macaddress="aa:bb:cc:dd:ee:ff", +) + async def test_full_flow( hass: HomeAssistant, @@ -111,3 +121,66 @@ async def test_exceptions( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_dhcp( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test 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 not result["errors"] + + 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["result"].unique_id == "test-id" + + +async def test_dhcp_mac( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test updating the mac address in the DHCP discovery.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, "KNC1-W-00000214")}) + assert device + assert device.connections == set() + + 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" + + device = device_registry.async_get_device(identifiers={(DOMAIN, "KNC1-W-00000214")}) + assert device + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + + +async def test_dhcp_already_setup( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery with already setup device.""" + mock_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/knocki/test_event.py b/tests/components/knocki/test_event.py index 4f639e08773..27d8b93bf64 100644 --- a/tests/components/knocki/test_event.py +++ b/tests/components/knocki/test_event.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from knocki import Event, EventType, Trigger, TriggerDetails import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.knocki.const import DOMAIN from homeassistant.const import STATE_UNKNOWN diff --git a/tests/components/knx/fixtures/config_store_cover.json b/tests/components/knx/fixtures/config_store_cover.json new file mode 100644 index 00000000000..6ec8dcc90fa --- /dev/null +++ b/tests/components/knx/fixtures/config_store_cover.json @@ -0,0 +1,82 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "cover": { + "knx_es_01JQNM9A9G03952ZH0GDF51HB6": { + "entity": { + "name": "minimal", + "entity_category": null, + "device_info": null + }, + "knx": { + "ga_up_down": { + "write": "1/0/1", + "passive": [] + }, + "travelling_time_down": 25.0, + "travelling_time_up": 25.0, + "sync_state": true + } + }, + "knx_es_01JQNQVEB7WT3MYCX61RK361F8": { + "entity": { + "name": "position_only", + "entity_category": null, + "device_info": null + }, + "knx": { + "ga_position_set": { + "write": "2/0/1", + "passive": [] + }, + "ga_position_state": { + "state": "2/0/0", + "passive": [] + }, + "invert_position": true, + "travelling_time_up": 25.0, + "travelling_time_down": 25.0, + "sync_state": true + } + }, + "knx_es_01JQNQSDS4ZW96TX27S2NT3FYQ": { + "entity": { + "name": "tiltable", + "entity_category": null, + "device_info": null + }, + "knx": { + "ga_up_down": { + "write": "3/0/1", + "passive": [] + }, + "ga_stop": { + "write": "3/0/2", + "passive": [] + }, + "ga_position_set": { + "write": "3/1/1", + "passive": [] + }, + "ga_position_state": { + "state": "3/1/0", + "passive": [] + }, + "ga_angle": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "travelling_time_down": 16.0, + "travelling_time_up": 16.0, + "invert_angle": true, + "sync_state": true + } + } + } + } + } +} diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 3e4c9408542..6ebe8192f69 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1033,7 +1033,7 @@ async def test_form_with_automatic_connection_handling( async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: - """Return flow in secure_tunnelling menu step.""" + """Return flow in secure_tunnel menu step.""" gateway = _gateway_descriptor( "192.168.0.1", 3675, @@ -1082,7 +1082,7 @@ async def test_get_secure_menu_step_manual_tunnelling( request_description_mock: MagicMock, hass: HomeAssistant, ) -> None: - """Test flow reaches secure_tunnellinn menu step from manual tunnelling configuration.""" + """Test flow reaches secure_tunnellinn menu step from manual tunneling configuration.""" gateway = _gateway_descriptor( "192.168.0.1", 3675, @@ -1129,7 +1129,7 @@ async def test_get_secure_menu_step_manual_tunnelling( async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: - """Test configure tunnelling secure keys manually.""" + """Test configure tunneling secure keys manually.""" menu_step = await _get_menu_step_secure_tunnel(hass) result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index 0604b575c5b..2bb568ceb13 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -1,10 +1,15 @@ """Test KNX cover.""" -from homeassistant.components.cover import CoverState +from typing import Any + +import pytest + +from homeassistant.components.cover import CoverEntityFeature, CoverState from homeassistant.components.knx.schema import CoverSchema -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import async_capture_events @@ -160,3 +165,103 @@ async def test_cover_tilt_move_short(hass: HomeAssistant, knx: KNXTestKit) -> No "cover", "open_cover_tilt", target={"entity_id": "cover.test"}, blocking=True ) await knx.assert_write("1/0/1", 0) + + +@pytest.mark.parametrize( + ("knx_data", "read_responses", "initial_state", "supported_features"), + [ + ( + { + "ga_up_down": {"write": "1/0/1"}, + "sync_state": True, + }, + {}, + STATE_UNKNOWN, + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + ), + ( + { + "ga_position_set": {"write": "2/0/1"}, + "ga_position_state": {"state": "2/0/0"}, + "sync_state": True, + }, + {"2/0/0": (0x00,)}, + CoverState.OPEN, + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION, + ), + ( + { + "ga_up_down": {"write": "3/0/1", "passive": []}, + "ga_stop": {"write": "3/0/2", "passive": []}, + "ga_position_set": {"write": "3/1/1", "passive": []}, + "ga_position_state": {"state": "3/1/0", "passive": []}, + "ga_angle": {"write": "3/2/1", "state": "3/2/0", "passive": []}, + "travelling_time_down": 16.0, + "travelling_time_up": 16.0, + "invert_angle": True, + "sync_state": True, + }, + {"3/1/0": (0x00,), "3/2/0": (0x00,)}, + CoverState.OPEN, + CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.STOP_TILT, + ), + ], +) +async def test_cover_ui_create( + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_data: dict[str, Any], + read_responses: dict[str, int | tuple[int]], + initial_state: str, + supported_features: int, +) -> None: + """Test creating a cover.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.COVER, + entity_data={"name": "test"}, + knx_data=knx_data, + ) + # created entity sends read-request to KNX bus + for ga, value in read_responses.items(): + await knx.assert_read(ga, response=value, ignore_order=True) + knx.assert_state("cover.test", initial_state, supported_features=supported_features) + + +async def test_cover_ui_load(knx: KNXTestKit) -> None: + """Test loading a cover from storage.""" + await knx.setup_integration(config_store_fixture="config_store_cover.json") + + await knx.assert_read("2/0/0", response=(0xFF,), ignore_order=True) + await knx.assert_read("3/1/0", response=(0xFF,), ignore_order=True) + await knx.assert_read("3/2/0", response=(0xFF,), ignore_order=True) + + knx.assert_state( + "cover.minimal", + STATE_UNKNOWN, + supported_features=CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + ) + knx.assert_state( + "cover.position_only", + CoverState.OPEN, + supported_features=CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION, + ) + knx.assert_state( + "cover.tiltable", + CoverState.CLOSED, + supported_features=CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.STOP_TILT, + ) diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index 6d4bf7e6007..3f8bc805855 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from homeassistant.components.knx.const import ( diff --git a/tests/components/knx/test_knx_selectors.py b/tests/components/knx/test_knx_selectors.py index 7b2f09af84b..12acf691c08 100644 --- a/tests/components/knx/test_knx_selectors.py +++ b/tests/components/knx/test_knx_selectors.py @@ -14,11 +14,49 @@ INVALID = "invalid" @pytest.mark.parametrize( ("selector_config", "data", "expected"), [ + # empty data is invalid ( {}, {}, - {"write": None, "state": None, "passive": []}, + {INVALID: "At least one group address must be set"}, ), + ( + {"write": False}, + {}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"passive": False}, + {}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"write": False, "state": False, "passive": False}, + {}, + {INVALID: "At least one group address must be set"}, + ), + # stale data is invalid + ( + {"write": False}, + {"write": "1/2/3"}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"write": False}, + {"passive": []}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"state": False}, + {"write": None}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"passive": False}, + {"passive": ["1/2/3"]}, + {INVALID: "At least one group address must be set"}, + ), + # valid data ( {}, {"write": "1/2/3"}, @@ -39,11 +77,6 @@ INVALID = "invalid" {"write": "1", "state": 2, "passive": ["1/2/3"]}, {"write": "1", "state": 2, "passive": ["1/2/3"]}, ), - ( - {"write": False}, - {"write": "1/2/3"}, - {"state": None, "passive": []}, - ), ( {"write": False}, {"state": "1/2/3"}, @@ -54,11 +87,6 @@ INVALID = "invalid" {"passive": ["1/2/3"]}, {"state": None, "passive": ["1/2/3"]}, ), - ( - {"passive": False}, - {"passive": ["1/2/3"]}, - {"write": None, "state": None}, - ), ( {"passive": False}, {"write": "1/2/3"}, @@ -68,12 +96,12 @@ INVALID = "invalid" ( {"write_required": True}, {}, - INVALID, + {INVALID: r"required key not provided*"}, ), ( {"state_required": True}, {}, - INVALID, + {INVALID: r"required key not provided*"}, ), ( {"write_required": True}, @@ -88,18 +116,18 @@ INVALID = "invalid" ( {"write_required": True}, {"state": "1/2/3"}, - INVALID, + {INVALID: r"required key not provided*"}, ), ( {"state_required": True}, {"write": "1/2/3"}, - INVALID, + {INVALID: r"required key not provided*"}, ), # dpt key ( {"dpt": ColorTempModes}, {"write": "1/2/3"}, - INVALID, + {INVALID: r"required key not provided*"}, ), ( {"dpt": ColorTempModes}, @@ -109,19 +137,19 @@ INVALID = "invalid" ( {"dpt": ColorTempModes}, {"write": "1/2/3", "state": None, "passive": [], "dpt": "invalid"}, - INVALID, + {INVALID: r"value must be one of ['5.001', '7.600', '9']*"}, ), ], ) def test_ga_selector( selector_config: dict[str, Any], data: dict[str, Any], - expected: str | dict[str, Any], + expected: dict[str, Any], ) -> None: """Test GASelector.""" selector = GASelector(**selector_config) - if expected == INVALID: - with pytest.raises(vol.Invalid): + if INVALID in expected: + with pytest.raises(vol.Invalid, match=expected[INVALID]): selector(data) else: result = selector(data) diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index bd9b9ad278d..b4e7ffc0695 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.kostal_plenticore.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -74,7 +75,7 @@ async def test_form_g1( return_value={"scb:network": {"Hostname": "scb"}} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -86,15 +87,15 @@ async def test_form_g1( mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") mock_apiclient.__aenter__.assert_called_once() mock_apiclient.__aexit__.assert_called_once() - mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.login.assert_called_once_with("test-password", service_code=None) mock_apiclient.get_settings.assert_called_once() mock_apiclient.get_setting_values.assert_called_once_with( "scb:network", "Hostname" ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "scb" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "scb" + assert result["data"] == { "host": "1.1.1.1", "password": "test-password", } @@ -140,7 +141,7 @@ async def test_form_g2( return_value={"scb:network": {"Network:Hostname": "scb"}} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -152,21 +153,91 @@ async def test_form_g2( mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") mock_apiclient.__aenter__.assert_called_once() mock_apiclient.__aexit__.assert_called_once() - mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.login.assert_called_once_with("test-password", service_code=None) mock_apiclient.get_settings.assert_called_once() mock_apiclient.get_setting_values.assert_called_once_with( "scb:network", "Network:Hostname" ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "scb" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "scb" + assert result["data"] == { "host": "1.1.1.1", "password": "test-password", } assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_g2_with_service_code( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, +) -> None: + """Test the config flow for G2 models with a Service Code.""" + + 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.kostal_plenticore.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + # mock of the context manager instance + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ), + ] + } + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={"scb:network": {"Network:Hostname": "scb"}} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + "service_code": "test-service-code", + }, + ) + await hass.async_block_till_done() + + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with( + "test-password", service_code="test-service-code" + ) + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with( + "scb:network", "Network:Hostname" + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "scb" + assert result["data"] == { + "host": "1.1.1.1", + "password": "test-password", + "service_code": "test-service-code", + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -189,7 +260,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: mock_api_class.return_value = mock_api - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -197,8 +268,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"password": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"password": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -223,7 +294,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: mock_api_class.return_value = mock_api - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -231,8 +302,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"host": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"host": "cannot_connect"} async def test_form_unexpected_error(hass: HomeAssistant) -> None: @@ -257,7 +328,7 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None: mock_api_class.return_value = mock_api - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -265,8 +336,8 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} async def test_already_configured(hass: HomeAssistant) -> None: @@ -281,7 +352,7 @@ async def test_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -289,5 +360,197 @@ async def test_already_configured(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the config flow for G1 models.""" + + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # mock of the context manager instance + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ), + ] + } + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={"scb:network": {"Hostname": "scb"}} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with("test-password", service_code=None) + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with("scb:network", "Hostname") + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data[CONF_HOST] == "1.1.1.1" + assert mock_config_entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_invalid_auth( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle invalid auth while reconfiguring.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.kostal_plenticore.config_flow.ApiClient" + ) as mock_api_class: + # mock of the context manager instance + mock_api_ctx = MagicMock() + mock_api_ctx.login = AsyncMock( + side_effect=AuthenticationException(404, "invalid user"), + ) + + # mock of the return instance of ApiClient + mock_api = MagicMock() + mock_api.__aenter__.return_value = mock_api_ctx + mock_api.__aexit__.return_value = None + + mock_api_class.return_value = mock_api + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"password": "invalid_auth"} + + +async def test_reconfigure_cannot_connect( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle cannot connect error.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.kostal_plenticore.config_flow.ApiClient" + ) as mock_api_class: + # mock of the context manager instance + mock_api_ctx = MagicMock() + mock_api_ctx.login = AsyncMock( + side_effect=TimeoutError(), + ) + + # mock of the return instance of ApiClient + mock_api = MagicMock() + mock_api.__aenter__.return_value = mock_api_ctx + mock_api.__aexit__.return_value = None + + mock_api_class.return_value = mock_api + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"host": "cannot_connect"} + + +async def test_reconfigure_unexpected_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle unexpected error.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.kostal_plenticore.config_flow.ApiClient" + ) as mock_api_class: + # mock of the context manager instance + mock_api_ctx = MagicMock() + mock_api_ctx.login = AsyncMock( + side_effect=Exception(), + ) + + # mock of the return instance of ApiClient + mock_api = MagicMock() + mock_api.__aenter__.return_value = mock_api_ctx + mock_api.__aexit__.return_value = None + + mock_api_class.return_value = mock_api + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_reconfigure_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle already configured error.""" + mock_config_entry.add_to_hass(hass) + MockConfigEntry( + domain="kostal_plenticore", + data={CONF_HOST: "1.1.1.1", CONF_PASSWORD: "foobar"}, + unique_id="112233445566", + ).add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index c7530d464db..ccfea1243bc 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -128,3 +128,13 @@ def mock_ble_device() -> BLEDevice: return BLEDevice( "00:00:00:00:00:00", "GS_GS012345", details={"path": "path"}, rssi=50 ) + + +@pytest.fixture +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 diff --git a/tests/components/lamarzocco/fixtures/config_gs3.json b/tests/components/lamarzocco/fixtures/config_gs3.json index 8958bb90fc4..80f535328d5 100644 --- a/tests/components/lamarzocco/fixtures/config_gs3.json +++ b/tests/components/lamarzocco/fixtures/config_gs3.json @@ -25,7 +25,7 @@ "status": "StandBy", "startTime": 1742857195332 }, - "brewingStartTime": null + "brewingStartTime": 1746641060000 }, "tutorialUrl": null }, diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index 0e772fb9653..0c72fd906a8 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Backflush active', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backflush_enabled', 'unique_id': 'GS012345_backflush_enabled', @@ -75,6 +76,7 @@ 'original_name': 'Brewing active', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brew_active', 'unique_id': 'GS012345_brew_active', @@ -123,6 +125,7 @@ 'original_name': 'Water tank empty', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_tank', 'unique_id': 'GS012345_water_tank', @@ -171,6 +174,7 @@ 'original_name': 'WebSocket connected', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'websocket_connected', 'unique_id': 'GS012345_websocket_connected', diff --git a/tests/components/lamarzocco/snapshots/test_button.ambr b/tests/components/lamarzocco/snapshots/test_button.ambr index 33aace5f97a..2f6d789b1a0 100644 --- a/tests/components/lamarzocco/snapshots/test_button.ambr +++ b/tests/components/lamarzocco/snapshots/test_button.ambr @@ -40,6 +40,7 @@ 'original_name': 'Start backflush', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_backflush', 'unique_id': 'GS012345_start_backflush', diff --git a/tests/components/lamarzocco/snapshots/test_calendar.ambr b/tests/components/lamarzocco/snapshots/test_calendar.ambr index 74847892cfa..60ba292d0f1 100644 --- a/tests/components/lamarzocco/snapshots/test_calendar.ambr +++ b/tests/components/lamarzocco/snapshots/test_calendar.ambr @@ -111,6 +111,7 @@ 'original_name': 'Auto on/off schedule (aXFz5bJ)', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off_schedule', 'unique_id': 'GS012345_auto_on_off_schedule_aXFz5bJ', @@ -145,6 +146,7 @@ 'original_name': 'Auto on/off schedule (Os2OswX)', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off_schedule', 'unique_id': 'GS012345_auto_on_off_schedule_Os2OswX', diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index 33b4b4092f7..9dcef0fe0f0 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -90,7 +90,7 @@ 'BrewingMode', 'StandBy', ]), - 'brewing_start_time': None, + 'brewing_start_time': '2025-05-07T18:04:20+00:00', 'mode': 'BrewingMode', 'next_status': dict({ 'start_time': '2025-03-24T22:59:55.332000+00:00', @@ -297,7 +297,7 @@ 'BrewingMode', 'StandBy', ]), - 'brewing_start_time': None, + 'brewing_start_time': '2025-05-07T18:04:20+00:00', 'mode': 'BrewingMode', 'next_status': dict({ 'start_time': '2025-03-24T22:59:55.332000+00:00', diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index 8f59ce4a6fa..5f451695443 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -51,6 +51,7 @@ 'original_name': 'Coffee target temperature', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'coffee_temp', 'unique_id': 'GS012345_coffee_temp', @@ -109,6 +110,7 @@ 'original_name': 'Smart standby time', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smart_standby_time', 'unique_id': 'GS012345_smart_standby_time', @@ -124,7 +126,7 @@ 'min': 0, 'mode': , 'step': 0.1, - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.mr012345_prebrew_off_time', @@ -167,10 +169,11 @@ 'original_name': 'Prebrew off time', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'prebrew_time_off', 'unique_id': 'MR012345_prebrew_off', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_prebrew_on[Linea Micra] @@ -182,7 +185,7 @@ 'min': 0, 'mode': , 'step': 0.1, - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.mr012345_prebrew_on_time', @@ -225,10 +228,11 @@ 'original_name': 'Prebrew on time', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'prebrew_time_on', 'unique_id': 'MR012345_prebrew_on', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_preinfusion[Linea Micra] @@ -283,6 +287,7 @@ 'original_name': 'Preinfusion time', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'preinfusion_time', 'unique_id': 'MR012345_preinfusion_off', diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr index 218b0092a49..701ce6b1cd2 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -51,6 +51,7 @@ 'original_name': 'Prebrew/-infusion mode', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'prebrew_infusion_select', 'unique_id': 'GS012345_prebrew_infusion_select', @@ -109,6 +110,7 @@ 'original_name': 'Prebrew/-infusion mode', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'prebrew_infusion_select', 'unique_id': 'MR012345_prebrew_infusion_select', @@ -167,6 +169,7 @@ 'original_name': 'Prebrew/-infusion mode', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'prebrew_infusion_select', 'unique_id': 'LM012345_prebrew_infusion_select', @@ -223,6 +226,7 @@ 'original_name': 'Smart standby mode', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smart_standby_mode', 'unique_id': 'GS012345_smart_standby_mode', @@ -281,6 +285,7 @@ 'original_name': 'Steam level', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'steam_temp_select', 'unique_id': 'MR012345_steam_temp_select', diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 46abb93dd2e..eea4616d0ff 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_sensors[sensor.gs012345_brewing_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_brewing_start_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': 'Brewing start time', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brewing_start_time', + 'unique_id': 'GS012345_brewing_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.gs012345_brewing_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Brewing start time', + }), + 'context': , + 'entity_id': 'sensor.gs012345_brewing_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-07T18:04:20+00:00', + }) +# --- # name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -27,6 +76,7 @@ 'original_name': 'Coffee boiler ready time', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'coffee_boiler_ready_time', 'unique_id': 'GS012345_coffee_boiler_ready_time', @@ -75,6 +125,7 @@ 'original_name': 'Last cleaning time', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_cleaning_time', 'unique_id': 'GS012345_last_cleaning_time', @@ -123,6 +174,7 @@ 'original_name': 'Steam boiler ready time', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'steam_boiler_ready_time', 'unique_id': 'GS012345_steam_boiler_ready_time', @@ -173,6 +225,7 @@ 'original_name': 'Total coffees made', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_coffees_made', 'unique_id': 'GS012345_drink_stats_coffee', @@ -224,6 +277,7 @@ 'original_name': 'Total flushes done', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_flushes_done', 'unique_id': 'GS012345_drink_stats_flushing', diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index 085d9a16125..1e36e36ef8b 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Auto on/off (Os2OswX)', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off', 'unique_id': 'GS012345_auto_on_off_Os2OswX', @@ -61,6 +62,7 @@ 'original_name': 'Auto on/off (aXFz5bJ)', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off', 'unique_id': 'GS012345_auto_on_off_aXFz5bJ', @@ -121,6 +123,7 @@ 'original_name': None, 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'main', 'unique_id': 'GS012345_main', @@ -168,6 +171,7 @@ 'original_name': 'Auto on/off (aXFz5bJ)', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off', 'unique_id': 'GS012345_auto_on_off_aXFz5bJ', @@ -215,6 +219,7 @@ 'original_name': 'Auto on/off (Os2OswX)', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off', 'unique_id': 'GS012345_auto_on_off_Os2OswX', @@ -262,6 +267,7 @@ 'original_name': 'Smart standby enabled', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smart_standby_enabled', 'unique_id': 'GS012345_smart_standby_enabled', @@ -309,6 +315,7 @@ 'original_name': 'Steam boiler', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'steam_boiler', 'unique_id': 'GS012345_steam_boiler_enable', diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index 508d0d36911..951e8a3d9db 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': 'Gateway firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'gateway_firmware', 'unique_id': 'GS012345_gateway_firmware', @@ -87,6 +88,7 @@ 'original_name': 'Machine firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'machine_firmware', 'unique_id': 'GS012345_machine_firmware', diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index 570b5aef8ec..ef8c7e17d97 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from pylamarzocco.exceptions import RequestNotSuccessful import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant @@ -17,6 +17,8 @@ from . import async_init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +pytestmark = pytest.mark.usefixtures("mock_websocket_terminated") + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( diff --git a/tests/components/lamarzocco/test_button.py b/tests/components/lamarzocco/test_button.py index 61b7ba77c22..2272829965b 100644 --- a/tests/components/lamarzocco/test_button.py +++ b/tests/components/lamarzocco/test_button.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from pylamarzocco.exceptions import RequestNotSuccessful import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID diff --git a/tests/components/lamarzocco/test_calendar.py b/tests/components/lamarzocco/test_calendar.py index 0d8db9bec89..8824de6d3f4 100644 --- a/tests/components/lamarzocco/test_calendar.py +++ b/tests/components/lamarzocco/test_calendar.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, diff --git a/tests/components/lamarzocco/test_diagnostics.py b/tests/components/lamarzocco/test_diagnostics.py index 762b33cc696..7aa0edcd0ad 100644 --- a/tests/components/lamarzocco/test_diagnostics.py +++ b/tests/components/lamarzocco/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the La Marzocco integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 31510ad1426..1e56e540e2a 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -6,7 +6,7 @@ from pylamarzocco.const import FirmwareType, ModelName from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import WebSocketDetails import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import DOMAIN diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py index e4be04f4ce4..b36f2944f4a 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -11,7 +11,7 @@ from pylamarzocco.const import ( ) from pylamarzocco.exceptions import RequestNotSuccessful import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py index 78cb9e313dd..845eda69d5b 100644 --- a/tests/components/lamarzocco/test_select.py +++ b/tests/components/lamarzocco/test_select.py @@ -10,7 +10,7 @@ from pylamarzocco.const import ( ) from pylamarzocco.exceptions import RequestNotSuccessful import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 0b050dd7788..183d3f2daa6 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch from pylamarzocco.const import ModelName import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -14,6 +14,8 @@ from . import async_init_integration from tests.common import MockConfigEntry, snapshot_platform +pytestmark = pytest.mark.usefixtures("mock_websocket_terminated") + async def test_sensors( hass: HomeAssistant, diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index b8e536e5c1b..0f1c4fd6ebb 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from pylamarzocco.const import SmartStandByType from pylamarzocco.exceptions import RequestNotSuccessful import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py index 3d02a0f38cf..99f85c21381 100644 --- a/tests/components/lamarzocco/test_update.py +++ b/tests/components/lamarzocco/test_update.py @@ -7,7 +7,7 @@ from pylamarzocco.const import FirmwareType, UpdateProgressInfo, UpdateStatus from pylamarzocco.exceptions import RequestNotSuccessful from pylamarzocco.models import UpdateDetails import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/lametric/test_diagnostics.py b/tests/components/lametric/test_diagnostics.py index e1fcbafcb73..8f42682ccfc 100644 --- a/tests/components/lametric/test_diagnostics.py +++ b/tests/components/lametric/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the LaMetric integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 1578c67432d..60373fa6c94 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest import serial -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from ultraheat_api.response import HeatMeterResponse from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index f319e37b265..5ded11d619a 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -125,7 +125,30 @@ "domain": "cover", "domain_data": { "motor": "MOTOR1", - "reverse_time": "RT1200" + "reverse_time": "RT1200", + "positioning_mode": "NONE" + } + }, + { + "address": [0, 7, false], + "name": "Cover_Relays_BS4", + "resource": "motor2", + "domain": "cover", + "domain_data": { + "motor": "MOTOR2", + "reverse_time": "RT1200", + "positioning_mode": "BS4" + } + }, + { + "address": [0, 7, false], + "name": "Cover_Relays_Module", + "resource": "motor3", + "domain": "cover", + "domain_data": { + "motor": "MOTOR3", + "reverse_time": "RT1200", + "positioning_mode": "MODULE" } }, { diff --git a/tests/components/lcn/snapshots/test_binary_sensor.ambr b/tests/components/lcn/snapshots/test_binary_sensor.ambr index 383c9038d78..d1a76b98bf1 100644 --- a/tests/components/lcn/snapshots/test_binary_sensor.ambr +++ b/tests/components/lcn/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_binary_sensor[binary_sensor.binary_sensor1-entry] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_binary_sensor1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.binary_sensor1', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.testmodule_binary_sensor1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -27,26 +27,27 @@ 'original_name': 'Binary_Sensor1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-binsensor1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.binary_sensor1-state] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_binary_sensor1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Binary_Sensor1', + 'friendly_name': 'TestModule Binary_Sensor1', }), 'context': , - 'entity_id': 'binary_sensor.binary_sensor1', + 'entity_id': 'binary_sensor.testmodule_binary_sensor1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_keylock-entry] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_keylock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +60,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.sensor_keylock', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.testmodule_sensor_keylock', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -74,26 +75,27 @@ 'original_name': 'Sensor_KeyLock', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-a5', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_keylock-state] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_keylock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sensor_KeyLock', + 'friendly_name': 'TestModule Sensor_KeyLock', }), 'context': , - 'entity_id': 'binary_sensor.sensor_keylock', + 'entity_id': 'binary_sensor.testmodule_sensor_keylock', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_lockregulator1-entry] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_lockregulator1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -106,8 +108,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.sensor_lockregulator1', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.testmodule_sensor_lockregulator1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -121,19 +123,20 @@ 'original_name': 'Sensor_LockRegulator1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_lockregulator1-state] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_lockregulator1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sensor_LockRegulator1', + 'friendly_name': 'TestModule Sensor_LockRegulator1', }), 'context': , - 'entity_id': 'binary_sensor.sensor_lockregulator1', + 'entity_id': 'binary_sensor.testmodule_sensor_lockregulator1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_climate.ambr b/tests/components/lcn/snapshots/test_climate.ambr index bd371c02492..ffc9a2fad4d 100644 --- a/tests/components/lcn/snapshots/test_climate.ambr +++ b/tests/components/lcn/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_climate[climate.climate1-entry] +# name: test_setup_lcn_climate[climate.testmodule_climate1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -19,8 +19,8 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.climate1', - 'has_entity_name': False, + 'entity_id': 'climate.testmodule_climate1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -34,17 +34,18 @@ 'original_name': 'Climate1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_climate[climate.climate1-state] +# name: test_setup_lcn_climate[climate.testmodule_climate1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': None, - 'friendly_name': 'Climate1', + 'friendly_name': 'TestModule Climate1', 'hvac_modes': list([ , , @@ -55,7 +56,7 @@ 'temperature': None, }), 'context': , - 'entity_id': 'climate.climate1', + 'entity_id': 'climate.testmodule_climate1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_cover.ambr b/tests/components/lcn/snapshots/test_cover.ambr index 3e9c4ee72eb..b5d02b8b43b 100644 --- a/tests/components/lcn/snapshots/test_cover.ambr +++ b/tests/components/lcn/snapshots/test_cover.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_cover[cover.cover_outputs-entry] +# name: test_setup_lcn_cover[cover.testmodule_cover_outputs-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.cover_outputs', - 'has_entity_name': False, + 'entity_id': 'cover.testmodule_cover_outputs', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -27,28 +27,29 @@ 'original_name': 'Cover_Outputs', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-outputs', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_cover[cover.cover_outputs-state] +# name: test_setup_lcn_cover[cover.testmodule_cover_outputs-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'assumed_state': True, - 'friendly_name': 'Cover_Outputs', + 'friendly_name': 'TestModule Cover_Outputs', 'supported_features': , }), 'context': , - 'entity_id': 'cover.cover_outputs', + 'entity_id': 'cover.testmodule_cover_outputs', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'open', }) # --- -# name: test_setup_lcn_cover[cover.cover_relays-entry] +# name: test_setup_lcn_cover[cover.testmodule_cover_relays-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -61,8 +62,8 @@ 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.cover_relays', - 'has_entity_name': False, + 'entity_id': 'cover.testmodule_cover_relays', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -76,21 +77,122 @@ 'original_name': 'Cover_Relays', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-motor1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_cover[cover.cover_relays-state] +# name: test_setup_lcn_cover[cover.testmodule_cover_relays-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'assumed_state': True, - 'friendly_name': 'Cover_Relays', + 'friendly_name': 'TestModule Cover_Relays', 'supported_features': , }), 'context': , - 'entity_id': 'cover.cover_relays', + 'entity_id': 'cover.testmodule_cover_relays', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_setup_lcn_cover[cover.testmodule_cover_relays_bs4-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.testmodule_cover_relays_bs4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cover_Relays_BS4', + 'platform': 'lcn', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk_json-m000007-motor2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_cover[cover.testmodule_cover_relays_bs4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'TestModule Cover_Relays_BS4', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.testmodule_cover_relays_bs4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_setup_lcn_cover[cover.testmodule_cover_relays_module-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.testmodule_cover_relays_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cover_Relays_Module', + 'platform': 'lcn', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk_json-m000007-motor3', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_cover[cover.testmodule_cover_relays_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'TestModule Cover_Relays_Module', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.testmodule_cover_relays_module', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_light.ambr b/tests/components/lcn/snapshots/test_light.ambr index 5bfd00fb0d7..6aaed89818d 100644 --- a/tests/components/lcn/snapshots/test_light.ambr +++ b/tests/components/lcn/snapshots/test_light.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_light[light.light_output1-entry] +# name: test_setup_lcn_light[light.testmodule_light_output1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -16,8 +16,8 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': None, - 'entity_id': 'light.light_output1', - 'has_entity_name': False, + 'entity_id': 'light.testmodule_light_output1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -31,32 +31,33 @@ 'original_name': 'Light_Output1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-output1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_light[light.light_output1-state] +# name: test_setup_lcn_light[light.testmodule_light_output1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'brightness': None, 'color_mode': None, - 'friendly_name': 'Light_Output1', + 'friendly_name': 'TestModule Light_Output1', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.light_output1', + 'entity_id': 'light.testmodule_light_output1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_light[light.light_output2-entry] +# name: test_setup_lcn_light[light.testmodule_light_output2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -73,8 +74,8 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': None, - 'entity_id': 'light.light_output2', - 'has_entity_name': False, + 'entity_id': 'light.testmodule_light_output2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -88,31 +89,32 @@ 'original_name': 'Light_Output2', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-output2', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_light[light.light_output2-state] +# name: test_setup_lcn_light[light.testmodule_light_output2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'color_mode': None, - 'friendly_name': 'Light_Output2', + 'friendly_name': 'TestModule Light_Output2', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.light_output2', + 'entity_id': 'light.testmodule_light_output2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_light[light.light_relay1-entry] +# name: test_setup_lcn_light[light.testmodule_light_relay1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -129,8 +131,8 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': None, - 'entity_id': 'light.light_relay1', - 'has_entity_name': False, + 'entity_id': 'light.testmodule_light_relay1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -144,24 +146,25 @@ 'original_name': 'Light_Relay1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_light[light.light_relay1-state] +# name: test_setup_lcn_light[light.testmodule_light_relay1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'color_mode': None, - 'friendly_name': 'Light_Relay1', + 'friendly_name': 'TestModule Light_Relay1', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.light_relay1', + 'entity_id': 'light.testmodule_light_relay1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_scene.ambr b/tests/components/lcn/snapshots/test_scene.ambr index 6dac4868437..21ba0894063 100644 --- a/tests/components/lcn/snapshots/test_scene.ambr +++ b/tests/components/lcn/snapshots/test_scene.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_scene[scene.romantic-entry] +# name: test_setup_lcn_scene[scene.testmodule_romantic-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'scene', 'entity_category': None, - 'entity_id': 'scene.romantic', - 'has_entity_name': False, + 'entity_id': 'scene.testmodule_romantic', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -27,26 +27,27 @@ 'original_name': 'Romantic', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-00', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_scene[scene.romantic-state] +# name: test_setup_lcn_scene[scene.testmodule_romantic-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Romantic', + 'friendly_name': 'TestModule Romantic', }), 'context': , - 'entity_id': 'scene.romantic', + 'entity_id': 'scene.testmodule_romantic', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_scene[scene.romantic_transition-entry] +# name: test_setup_lcn_scene[scene.testmodule_romantic_transition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +60,8 @@ 'disabled_by': None, 'domain': 'scene', 'entity_category': None, - 'entity_id': 'scene.romantic_transition', - 'has_entity_name': False, + 'entity_id': 'scene.testmodule_romantic_transition', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -74,19 +75,20 @@ 'original_name': 'Romantic Transition', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-01', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_scene[scene.romantic_transition-state] +# name: test_setup_lcn_scene[scene.testmodule_romantic_transition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Romantic Transition', + 'friendly_name': 'TestModule Romantic Transition', }), 'context': , - 'entity_id': 'scene.romantic_transition', + 'entity_id': 'scene.testmodule_romantic_transition', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_sensor.ambr b/tests/components/lcn/snapshots/test_sensor.ambr index 1e172dda7e9..e96f6ccd643 100644 --- a/tests/components/lcn/snapshots/test_sensor.ambr +++ b/tests/components/lcn/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_sensor[sensor.sensor_led6-entry] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_led6-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensor_led6', - 'has_entity_name': False, + 'entity_id': 'sensor.testmodule_sensor_led6', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -27,26 +27,27 @@ 'original_name': 'Sensor_Led6', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-led6', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_led6-state] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_led6-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sensor_Led6', + 'friendly_name': 'TestModule Sensor_Led6', }), 'context': , - 'entity_id': 'sensor.sensor_led6', + 'entity_id': 'sensor.testmodule_sensor_led6', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_logicop1-entry] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_logicop1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +60,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensor_logicop1', - 'has_entity_name': False, + 'entity_id': 'sensor.testmodule_sensor_logicop1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -74,26 +75,27 @@ 'original_name': 'Sensor_LogicOp1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-logicop1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_logicop1-state] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_logicop1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sensor_LogicOp1', + 'friendly_name': 'TestModule Sensor_LogicOp1', }), 'context': , - 'entity_id': 'sensor.sensor_logicop1', + 'entity_id': 'sensor.testmodule_sensor_logicop1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_setpoint1-entry] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_setpoint1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -106,8 +108,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensor_setpoint1', - 'has_entity_name': False, + 'entity_id': 'sensor.testmodule_sensor_setpoint1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -115,34 +117,38 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Sensor_Setpoint1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': , }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_setpoint1-state] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_setpoint1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Sensor_Setpoint1', + 'friendly_name': 'TestModule Sensor_Setpoint1', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.sensor_setpoint1', + 'entity_id': 'sensor.testmodule_sensor_setpoint1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_var1-entry] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_var1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -155,8 +161,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensor_var1', - 'has_entity_name': False, + 'entity_id': 'sensor.testmodule_sensor_var1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -164,27 +170,31 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Sensor_Var1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-var1', 'unit_of_measurement': , }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_var1-state] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_var1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Sensor_Var1', + 'friendly_name': 'TestModule Sensor_Var1', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.sensor_var1', + 'entity_id': 'sensor.testmodule_sensor_var1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_switch.ambr b/tests/components/lcn/snapshots/test_switch.ambr index 7ba943a671f..89d4d12cf35 100644 --- a/tests/components/lcn/snapshots/test_switch.ambr +++ b/tests/components/lcn/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_switch[switch.switch_group5-entry] +# name: test_setup_lcn_switch[switch.testgroup_switch_group5-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_group5', - 'has_entity_name': False, + 'entity_id': 'switch.testgroup_switch_group5', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -27,26 +27,27 @@ 'original_name': 'Switch_Group5', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-g000005-relay1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_group5-state] +# name: test_setup_lcn_switch[switch.testgroup_switch_group5-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Group5', + 'friendly_name': 'TestGroup Switch_Group5', }), 'context': , - 'entity_id': 'switch.switch_group5', + 'entity_id': 'switch.testgroup_switch_group5', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_keylock1-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_keylock1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +60,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_keylock1', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_keylock1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -74,26 +75,27 @@ 'original_name': 'Switch_KeyLock1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-a1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_keylock1-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_keylock1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_KeyLock1', + 'friendly_name': 'TestModule Switch_KeyLock1', }), 'context': , - 'entity_id': 'switch.switch_keylock1', + 'entity_id': 'switch.testmodule_switch_keylock1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_output1-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_output1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -106,8 +108,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_output1', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_output1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -121,26 +123,27 @@ 'original_name': 'Switch_Output1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-output1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_output1-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_output1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Output1', + 'friendly_name': 'TestModule Switch_Output1', }), 'context': , - 'entity_id': 'switch.switch_output1', + 'entity_id': 'switch.testmodule_switch_output1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_output2-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_output2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -153,8 +156,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_output2', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_output2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -168,26 +171,27 @@ 'original_name': 'Switch_Output2', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-output2', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_output2-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_output2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Output2', + 'friendly_name': 'TestModule Switch_Output2', }), 'context': , - 'entity_id': 'switch.switch_output2', + 'entity_id': 'switch.testmodule_switch_output2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_regulator1-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_regulator1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -200,8 +204,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_regulator1', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_regulator1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -215,26 +219,27 @@ 'original_name': 'Switch_Regulator1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_regulator1-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_regulator1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Regulator1', + 'friendly_name': 'TestModule Switch_Regulator1', }), 'context': , - 'entity_id': 'switch.switch_regulator1', + 'entity_id': 'switch.testmodule_switch_regulator1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_relay1-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_relay1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -247,8 +252,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_relay1', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_relay1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -262,26 +267,27 @@ 'original_name': 'Switch_Relay1', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_relay1-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_relay1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Relay1', + 'friendly_name': 'TestModule Switch_Relay1', }), 'context': , - 'entity_id': 'switch.switch_relay1', + 'entity_id': 'switch.testmodule_switch_relay1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_relay2-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_relay2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -294,8 +300,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_relay2', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_relay2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -309,19 +315,20 @@ 'original_name': 'Switch_Relay2', 'platform': 'lcn', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay2', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_relay2-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_relay2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Relay2', + 'friendly_name': 'TestModule Switch_Relay2', }), 'context': , - 'entity_id': 'switch.switch_relay2', + 'entity_id': 'switch.testmodule_switch_relay2', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 7d636f546c4..b9362dcd242 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -22,9 +22,9 @@ from .conftest import MockConfigEntry, init_integration from tests.common import snapshot_platform -BINARY_SENSOR_LOCKREGULATOR1 = "binary_sensor.sensor_lockregulator1" -BINARY_SENSOR_SENSOR1 = "binary_sensor.binary_sensor1" -BINARY_SENSOR_KEYLOCK = "binary_sensor.sensor_keylock" +BINARY_SENSOR_LOCKREGULATOR1 = "binary_sensor.testmodule_sensor_lockregulator1" +BINARY_SENSOR_SENSOR1 = "binary_sensor.testmodule_binary_sensor1" +BINARY_SENSOR_KEYLOCK = "binary_sensor.testmodule_sensor_keylock" async def test_setup_lcn_binary_sensor( @@ -140,7 +140,11 @@ async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) @pytest.mark.parametrize( - "entity_id", ["binary_sensor.sensor_lockregulator1", "binary_sensor.sensor_keylock"] + "entity_id", + [ + "binary_sensor.testmodule_sensor_lockregulator1", + "binary_sensor.testmodule_sensor_keylock", + ], ) async def test_create_issue( hass: HomeAssistant, @@ -186,5 +190,3 @@ async def test_create_issue( assert issue_registry.async_get_issue( DOMAIN, f"deprecated_binary_sensor_{entity_id}" ) - - assert len(issue_registry.issues) == 1 diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index 7bac7cc9e81..ceb6f9524d1 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -52,7 +52,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - await init_integration(hass, entry) with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") state.state = HVACMode.OFF # command failed @@ -61,13 +61,16 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.HEAT}, + { + ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, blocking=True, ) lock_regulator.assert_awaited_with(0, False) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state != HVACMode.HEAT @@ -78,13 +81,16 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.HEAT}, + { + ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, blocking=True, ) lock_regulator.assert_awaited_with(0, False) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.HEAT @@ -94,7 +100,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> await init_integration(hass, entry) with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") state.state = HVACMode.HEAT # command failed @@ -103,13 +109,16 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.OFF}, + { + ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_HVAC_MODE: HVACMode.OFF, + }, blocking=True, ) lock_regulator.assert_awaited_with(0, True, -1) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state != HVACMode.OFF @@ -120,13 +129,16 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.OFF}, + { + ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_HVAC_MODE: HVACMode.OFF, + }, blocking=True, ) lock_regulator.assert_awaited_with(0, True, -1) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.OFF @@ -136,7 +148,7 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N await init_integration(hass, entry) with patch.object(MockModuleConnection, "var_abs") as var_abs: - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") state.state = HVACMode.HEAT # wrong temperature set via service call with high/low attributes @@ -147,7 +159,7 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.climate1", + ATTR_ENTITY_ID: "climate.testmodule_climate1", ATTR_TARGET_TEMP_LOW: 24.5, ATTR_TARGET_TEMP_HIGH: 25.5, }, @@ -163,13 +175,13 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_TEMPERATURE: 25.5}, + {ATTR_ENTITY_ID: "climate.testmodule_climate1", ATTR_TEMPERATURE: 25.5}, blocking=True, ) var_abs.assert_awaited_with(Var.R1VARSETPOINT, 25.5, VarUnit.CELSIUS) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.attributes[ATTR_TEMPERATURE] != 25.5 @@ -180,13 +192,13 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_TEMPERATURE: 25.5}, + {ATTR_ENTITY_ID: "climate.testmodule_climate1", ATTR_TEMPERATURE: 25.5}, blocking=True, ) var_abs.assert_awaited_with(Var.R1VARSETPOINT, 25.5, VarUnit.CELSIUS) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.attributes[ATTR_TEMPERATURE] == 25.5 @@ -207,7 +219,7 @@ async def test_pushed_current_temperature_status_change( await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.HEAT assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.5 @@ -230,7 +242,7 @@ async def test_pushed_setpoint_status_change( await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.HEAT assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -253,7 +265,7 @@ async def test_pushed_lock_status_change( await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.OFF assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -272,7 +284,7 @@ async def test_pushed_wrong_input( await device_connection.async_process_input(Unknown("input")) await hass.async_block_till_done() - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None assert state.attributes[ATTR_TEMPERATURE] is None @@ -285,5 +297,5 @@ async def test_unload_config_entry( await init_integration(hass, entry) await hass.config_entries.async_unload(entry.entry_id) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index 478f2c0949e..ef99a19dee4 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -94,8 +94,8 @@ async def test_step_user_existing_host( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_data ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {CONF_BASE: "already_configured"} + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.parametrize( diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index ff4311b6687..1ac4ea6f664 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -2,17 +2,29 @@ from unittest.mock import patch -from pypck.inputs import ModStatusOutput, ModStatusRelays +from pypck.inputs import ( + ModStatusMotorPositionBS4, + ModStatusMotorPositionModule, + ModStatusOutput, + ModStatusRelays, +) from pypck.lcn_addr import LcnAddr -from pypck.lcn_defs import MotorReverseTime, MotorStateModifier +from pypck.lcn_defs import MotorPositioningMode, MotorReverseTime, MotorStateModifier +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.cover import DOMAIN as DOMAIN_COVER, CoverState +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as DOMAIN_COVER, + CoverState, +) from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, STATE_UNAVAILABLE, Platform, @@ -24,8 +36,10 @@ from .conftest import MockConfigEntry, MockModuleConnection, init_integration from tests.common import snapshot_platform -COVER_OUTPUTS = "cover.cover_outputs" -COVER_RELAYS = "cover.cover_relays" +COVER_OUTPUTS = "cover.testmodule_cover_outputs" +COVER_RELAYS = "cover.testmodule_cover_relays" +COVER_RELAYS_BS4 = "cover.testmodule_cover_relays_bs4" +COVER_RELAYS_MODULE = "cover.testmodule_cover_relays_module" async def test_setup_lcn_cover( @@ -46,13 +60,13 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_outputs" - ) as control_motors_outputs: + MockModuleConnection, "control_motor_outputs" + ) as control_motor_outputs: state = hass.states.get(COVER_OUTPUTS) state.state = CoverState.CLOSED # command failed - control_motors_outputs.return_value = False + control_motor_outputs.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -61,7 +75,7 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_outputs.assert_awaited_with( + control_motor_outputs.assert_awaited_with( MotorStateModifier.UP, MotorReverseTime.RT1200 ) @@ -70,8 +84,8 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None assert state.state != CoverState.OPENING # command success - control_motors_outputs.reset_mock(return_value=True) - control_motors_outputs.return_value = True + control_motor_outputs.reset_mock(return_value=True) + control_motor_outputs.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -80,7 +94,7 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_outputs.assert_awaited_with( + control_motor_outputs.assert_awaited_with( MotorStateModifier.UP, MotorReverseTime.RT1200 ) @@ -94,13 +108,13 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_outputs" - ) as control_motors_outputs: + MockModuleConnection, "control_motor_outputs" + ) as control_motor_outputs: state = hass.states.get(COVER_OUTPUTS) state.state = CoverState.OPEN # command failed - control_motors_outputs.return_value = False + control_motor_outputs.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -109,7 +123,7 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non blocking=True, ) - control_motors_outputs.assert_awaited_with( + control_motor_outputs.assert_awaited_with( MotorStateModifier.DOWN, MotorReverseTime.RT1200 ) @@ -118,8 +132,8 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non assert state.state != CoverState.CLOSING # command success - control_motors_outputs.reset_mock(return_value=True) - control_motors_outputs.return_value = True + control_motor_outputs.reset_mock(return_value=True) + control_motor_outputs.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -128,7 +142,7 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non blocking=True, ) - control_motors_outputs.assert_awaited_with( + control_motor_outputs.assert_awaited_with( MotorStateModifier.DOWN, MotorReverseTime.RT1200 ) @@ -142,13 +156,13 @@ async def test_outputs_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_outputs" - ) as control_motors_outputs: + MockModuleConnection, "control_motor_outputs" + ) as control_motor_outputs: state = hass.states.get(COVER_OUTPUTS) state.state = CoverState.CLOSING # command failed - control_motors_outputs.return_value = False + control_motor_outputs.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -157,15 +171,15 @@ async def test_outputs_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) + control_motor_outputs.assert_awaited_with(MotorStateModifier.STOP) state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == CoverState.CLOSING # command success - control_motors_outputs.reset_mock(return_value=True) - control_motors_outputs.return_value = True + control_motor_outputs.reset_mock(return_value=True) + control_motor_outputs.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -174,7 +188,7 @@ async def test_outputs_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) + control_motor_outputs.assert_awaited_with(MotorStateModifier.STOP) state = hass.states.get(COVER_OUTPUTS) assert state is not None @@ -186,16 +200,13 @@ async def test_relays_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_relays" - ) as control_motors_relays: - states = [MotorStateModifier.NOCHANGE] * 4 - states[0] = MotorStateModifier.UP - + MockModuleConnection, "control_motor_relays" + ) as control_motor_relays: state = hass.states.get(COVER_RELAYS) state.state = CoverState.CLOSED # command failed - control_motors_relays.return_value = False + control_motor_relays.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -204,15 +215,17 @@ async def test_relays_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.UP, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state != CoverState.OPENING # command success - control_motors_relays.reset_mock(return_value=True) - control_motors_relays.return_value = True + control_motor_relays.reset_mock(return_value=True) + control_motor_relays.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -221,7 +234,9 @@ async def test_relays_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.UP, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None @@ -233,16 +248,13 @@ async def test_relays_close(hass: HomeAssistant, entry: MockConfigEntry) -> None await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_relays" - ) as control_motors_relays: - states = [MotorStateModifier.NOCHANGE] * 4 - states[0] = MotorStateModifier.DOWN - + MockModuleConnection, "control_motor_relays" + ) as control_motor_relays: state = hass.states.get(COVER_RELAYS) state.state = CoverState.OPEN # command failed - control_motors_relays.return_value = False + control_motor_relays.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -251,15 +263,17 @@ async def test_relays_close(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.DOWN, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state != CoverState.CLOSING # command success - control_motors_relays.reset_mock(return_value=True) - control_motors_relays.return_value = True + control_motor_relays.reset_mock(return_value=True) + control_motor_relays.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -268,7 +282,9 @@ async def test_relays_close(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.DOWN, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None @@ -280,16 +296,13 @@ async def test_relays_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_relays" - ) as control_motors_relays: - states = [MotorStateModifier.NOCHANGE] * 4 - states[0] = MotorStateModifier.STOP - + MockModuleConnection, "control_motor_relays" + ) as control_motor_relays: state = hass.states.get(COVER_RELAYS) state.state = CoverState.CLOSING # command failed - control_motors_relays.return_value = False + control_motor_relays.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -298,15 +311,17 @@ async def test_relays_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.STOP, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == CoverState.CLOSING # command success - control_motors_relays.reset_mock(return_value=True) - control_motors_relays.return_value = True + control_motor_relays.reset_mock(return_value=True) + control_motor_relays.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -315,13 +330,74 @@ async def test_relays_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.STOP, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state not in (CoverState.CLOSING, CoverState.OPENING) +@pytest.mark.parametrize( + ("entity_id", "motor", "positioning_mode"), + [ + (COVER_RELAYS_BS4, 1, MotorPositioningMode.BS4), + (COVER_RELAYS_MODULE, 2, MotorPositioningMode.MODULE), + ], +) +async def test_relays_set_position( + hass: HomeAssistant, + entry: MockConfigEntry, + entity_id: str, + motor: int, + positioning_mode: MotorPositioningMode, +) -> None: + """Test the relays cover moves to position.""" + await init_integration(hass, entry) + + with patch.object( + MockModuleConnection, "control_motor_relays_position" + ) as control_motor_relays_position: + state = hass.states.get(entity_id) + state.state = CoverState.CLOSED + + # command failed + control_motor_relays_position.return_value = False + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + + control_motor_relays_position.assert_awaited_with( + motor, 50, mode=positioning_mode + ) + + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + + # command success + control_motor_relays_position.reset_mock(return_value=True) + control_motor_relays_position.return_value = True + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + + control_motor_relays_position.assert_awaited_with( + motor, 50, mode=positioning_mode + ) + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + + async def test_pushed_outputs_status_change( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -372,8 +448,9 @@ async def test_pushed_relays_status_change( address = LcnAddr(0, 7, False) states = [False] * 8 - state = hass.states.get(COVER_RELAYS) - state.state = CoverState.CLOSED + for entity_id in (COVER_RELAYS, COVER_RELAYS_BS4, COVER_RELAYS_MODULE): + state = hass.states.get(entity_id) + state.state = CoverState.CLOSED # push status "open" states[0:2] = [True, False] @@ -405,6 +482,26 @@ async def test_pushed_relays_status_change( assert state is not None assert state.state == CoverState.CLOSING + # push status "set position" via BS4 + inp = ModStatusMotorPositionBS4(address, 1, 50) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(COVER_RELAYS_BS4) + assert state is not None + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + # push status "set position" via MODULE + inp = ModStatusMotorPositionModule(address, 2, 75) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(COVER_RELAYS_MODULE) + assert state is not None + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 75 + async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the cover is removed when the config entry is unloaded.""" diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index da967782539..5634449bf22 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -178,8 +178,8 @@ async def test_migrate_2_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> @pytest.mark.parametrize( ("entity_id", "replace"), [ - ("climate.climate1", ("-r1varsetpoint", "-var1.r1varsetpoint")), - ("scene.romantic", ("-00", "-0.0")), + ("climate.testmodule_climate1", ("-r1varsetpoint", "-var1.r1varsetpoint")), + ("scene.testmodule_romantic", ("-00", "-0.0")), ], ) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index 4251d997724..00c2341631e 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -29,9 +29,9 @@ from .conftest import MockConfigEntry, MockModuleConnection, init_integration from tests.common import snapshot_platform -LIGHT_OUTPUT1 = "light.light_output1" -LIGHT_OUTPUT2 = "light.light_output2" -LIGHT_RELAY1 = "light.light_relay1" +LIGHT_OUTPUT1 = "light.testmodule_light_output1" +LIGHT_OUTPUT2 = "light.testmodule_light_output2" +LIGHT_RELAY1 = "light.testmodule_light_relay1" async def test_setup_lcn_light( diff --git a/tests/components/lcn/test_scene.py b/tests/components/lcn/test_scene.py index 27e7864df41..aaf17f292c1 100644 --- a/tests/components/lcn/test_scene.py +++ b/tests/components/lcn/test_scene.py @@ -43,11 +43,11 @@ async def test_scene_activate( await hass.services.async_call( DOMAIN_SCENE, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "scene.romantic"}, + {ATTR_ENTITY_ID: "scene.testmodule_romantic"}, blocking=True, ) - state = hass.states.get("scene.romantic") + state = hass.states.get("scene.testmodule_romantic") assert state is not None activate_scene.assert_awaited_with( @@ -60,5 +60,5 @@ async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) await init_integration(hass, entry) await hass.config_entries.async_unload(entry.entry_id) - state = hass.states.get("scene.romantic") + state = hass.states.get("scene.testmodule_romantic") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_sensor.py b/tests/components/lcn/test_sensor.py index 18335f4b073..85f5b62bf91 100644 --- a/tests/components/lcn/test_sensor.py +++ b/tests/components/lcn/test_sensor.py @@ -16,10 +16,10 @@ from .conftest import MockConfigEntry, init_integration from tests.common import snapshot_platform -SENSOR_VAR1 = "sensor.sensor_var1" -SENSOR_SETPOINT1 = "sensor.sensor_setpoint1" -SENSOR_LED6 = "sensor.sensor_led6" -SENSOR_LOGICOP1 = "sensor.sensor_logicop1" +SENSOR_VAR1 = "sensor.testmodule_sensor_var1" +SENSOR_SETPOINT1 = "sensor.testmodule_sensor_setpoint1" +SENSOR_LED6 = "sensor.testmodule_sensor_led6" +SENSOR_LOGICOP1 = "sensor.testmodule_sensor_logicop1" async def test_setup_lcn_sensor( diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index c9eda40fdba..cdc8e9671c0 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -24,14 +24,12 @@ from homeassistant.components.lcn.const import ( ) from homeassistant.components.lcn.services import LcnService from homeassistant.const import ( - CONF_ADDRESS, CONF_BRIGHTNESS, CONF_DEVICE_ID, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import ( @@ -42,20 +40,9 @@ from .conftest import ( ) -def device_config( - hass: HomeAssistant, entry: MockConfigEntry, config_type: str -) -> dict[str, str]: - """Return test device config depending on type.""" - if config_type == CONF_ADDRESS: - return {CONF_ADDRESS: "pchk.s0.m7"} - return {CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id} - - -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_output_abs( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test output_abs service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -66,7 +53,7 @@ async def test_service_output_abs( DOMAIN, LcnService.OUTPUT_ABS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_OUTPUT: "output1", CONF_BRIGHTNESS: 100, CONF_TRANSITION: 5, @@ -77,11 +64,9 @@ async def test_service_output_abs( dim_output.assert_awaited_with(0, 100, 9) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_output_rel( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test output_rel service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -92,7 +77,7 @@ async def test_service_output_rel( DOMAIN, LcnService.OUTPUT_REL, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_OUTPUT: "output1", CONF_BRIGHTNESS: 25, }, @@ -102,11 +87,9 @@ async def test_service_output_rel( rel_output.assert_awaited_with(0, 25) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_output_toggle( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test output_toggle service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -117,7 +100,7 @@ async def test_service_output_toggle( DOMAIN, LcnService.OUTPUT_TOGGLE, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_OUTPUT: "output1", CONF_TRANSITION: 5, }, @@ -127,11 +110,9 @@ async def test_service_output_toggle( toggle_output.assert_awaited_with(0, 9) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_relays( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test relays service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -141,7 +122,10 @@ async def test_service_relays( await hass.services.async_call( DOMAIN, LcnService.RELAYS, - {**device_config(hass, entry, config_type), CONF_STATE: "0011TT--"}, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_STATE: "0011TT--", + }, blocking=True, ) @@ -151,11 +135,9 @@ async def test_service_relays( control_relays.assert_awaited_with(relay_states) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_led( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test led service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -166,7 +148,7 @@ async def test_service_led( DOMAIN, LcnService.LED, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_LED: "led6", CONF_STATE: "blink", }, @@ -179,11 +161,9 @@ async def test_service_led( control_led.assert_awaited_with(led, led_state) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_var_abs( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test var_abs service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -194,7 +174,7 @@ async def test_service_var_abs( DOMAIN, LcnService.VAR_ABS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_VARIABLE: "var1", CONF_VALUE: 75, CONF_UNIT_OF_MEASUREMENT: "%", @@ -207,11 +187,9 @@ async def test_service_var_abs( ) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_var_rel( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test var_rel service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -222,7 +200,7 @@ async def test_service_var_rel( DOMAIN, LcnService.VAR_REL, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_VARIABLE: "var1", CONF_VALUE: 10, CONF_UNIT_OF_MEASUREMENT: "%", @@ -239,11 +217,9 @@ async def test_service_var_rel( ) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_var_reset( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test var_reset service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -253,18 +229,19 @@ async def test_service_var_reset( await hass.services.async_call( DOMAIN, LcnService.VAR_RESET, - {**device_config(hass, entry, config_type), CONF_VARIABLE: "var1"}, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_VARIABLE: "var1", + }, blocking=True, ) var_reset.assert_awaited_with(pypck.lcn_defs.Var["VAR1"]) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_lock_regulator( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test lock_regulator service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -275,7 +252,7 @@ async def test_service_lock_regulator( DOMAIN, LcnService.LOCK_REGULATOR, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_SETPOINT: "r1varsetpoint", CONF_STATE: True, }, @@ -285,11 +262,9 @@ async def test_service_lock_regulator( lock_regulator.assert_awaited_with(0, True) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_send_keys( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test send_keys service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -300,7 +275,7 @@ async def test_service_send_keys( DOMAIN, LcnService.SEND_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_KEYS: "a1a5d8", CONF_STATE: "hit", }, @@ -315,11 +290,9 @@ async def test_service_send_keys( send_keys.assert_awaited_with(keys, pypck.lcn_defs.SendKeyCommand["HIT"]) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_send_keys_hit_deferred( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test send_keys (hit_deferred) service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -338,7 +311,7 @@ async def test_service_send_keys_hit_deferred( DOMAIN, LcnService.SEND_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_KEYS: "a1a5d8", CONF_TIME: 5, CONF_TIME_UNIT: "s", @@ -361,7 +334,7 @@ async def test_service_send_keys_hit_deferred( DOMAIN, LcnService.SEND_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_KEYS: "a1a5d8", CONF_STATE: "make", CONF_TIME: 5, @@ -371,11 +344,9 @@ async def test_service_send_keys_hit_deferred( ) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_lock_keys( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test lock_keys service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -386,7 +357,7 @@ async def test_service_lock_keys( DOMAIN, LcnService.LOCK_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_TABLE: "a", CONF_STATE: "0011TT--", }, @@ -399,11 +370,9 @@ async def test_service_lock_keys( lock_keys.assert_awaited_with(0, lock_states) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_lock_keys_tab_a_temporary( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test lock_keys (tab_a_temporary) service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -417,7 +386,7 @@ async def test_service_lock_keys_tab_a_temporary( DOMAIN, LcnService.LOCK_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_STATE: "0011TT--", CONF_TIME: 10, CONF_TIME_UNIT: "s", @@ -443,7 +412,7 @@ async def test_service_lock_keys_tab_a_temporary( DOMAIN, LcnService.LOCK_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_TABLE: "b", CONF_STATE: "0011TT--", CONF_TIME: 10, @@ -453,11 +422,9 @@ async def test_service_lock_keys_tab_a_temporary( ) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_dyn_text( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test dyn_text service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -468,7 +435,7 @@ async def test_service_dyn_text( DOMAIN, LcnService.DYN_TEXT, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_ROW: 1, CONF_TEXT: "text in row 1", }, @@ -478,11 +445,9 @@ async def test_service_dyn_text( dyn_text.assert_awaited_with(0, "text in row 1") -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_pck( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test pck service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -492,43 +457,11 @@ async def test_service_pck( await hass.services.async_call( DOMAIN, LcnService.PCK, - {**device_config(hass, entry, config_type), CONF_PCK: "PIN4"}, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_PCK: "PIN4", + }, blocking=True, ) pck.assert_awaited_with("PIN4") - - -async def test_service_called_with_invalid_host_id( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test service was called with non existing host id.""" - await async_setup_component(hass, "persistent_notification", {}) - await init_integration(hass, entry) - - with patch.object(MockModuleConnection, "pck") as pck, pytest.raises(ValueError): - await hass.services.async_call( - DOMAIN, - LcnService.PCK, - {CONF_ADDRESS: "foobar.s0.m7", CONF_PCK: "PIN4"}, - blocking=True, - ) - - pck.assert_not_awaited() - - -async def test_service_with_deprecated_address_parameter( - hass: HomeAssistant, entry: MockConfigEntry, issue_registry: ir.IssueRegistry -) -> None: - """Test service puts issue in registry if called with address parameter.""" - await async_setup_component(hass, "persistent_notification", {}) - await init_integration(hass, entry) - - await hass.services.async_call( - DOMAIN, - LcnService.PCK, - {CONF_ADDRESS: "pchk.s0.m7", CONF_PCK: "PIN4"}, - blocking=True, - ) - - assert issue_registry.async_get_issue(DOMAIN, "deprecated_address_parameter") diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py index 15b156aac43..0c0067c8875 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -30,12 +30,12 @@ from .conftest import MockConfigEntry, MockModuleConnection, init_integration from tests.common import snapshot_platform -SWITCH_OUTPUT1 = "switch.switch_output1" -SWITCH_OUTPUT2 = "switch.switch_output2" -SWITCH_RELAY1 = "switch.switch_relay1" -SWITCH_RELAY2 = "switch.switch_relay2" -SWITCH_REGULATOR1 = "switch.switch_regulator1" -SWITCH_KEYLOCKK1 = "switch.switch_keylock1" +SWITCH_OUTPUT1 = "switch.testmodule_switch_output1" +SWITCH_OUTPUT2 = "switch.testmodule_switch_output2" +SWITCH_RELAY1 = "switch.testmodule_switch_relay1" +SWITCH_RELAY2 = "switch.testmodule_switch_relay2" +SWITCH_REGULATOR1 = "switch.testmodule_switch_regulator1" +SWITCH_KEYLOCKK1 = "switch.testmodule_switch_keylock1" async def test_setup_lcn_switch( diff --git a/tests/components/lektrico/snapshots/test_binary_sensor.ambr b/tests/components/lektrico/snapshots/test_binary_sensor.ambr index 7d812c0fc67..11fb3aa5a0a 100644 --- a/tests/components/lektrico/snapshots/test_binary_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'EV diode short', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cp_diode_failure', 'unique_id': '500006_cp_diode_failure', @@ -75,6 +76,7 @@ 'original_name': 'EV error', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state_e_activated', 'unique_id': '500006_state_e_activated', @@ -123,6 +125,7 @@ 'original_name': 'Metering error', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_fault', 'unique_id': '500006_meter_fault', @@ -171,6 +174,7 @@ 'original_name': 'Overcurrent', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'overcurrent', 'unique_id': '500006_overcurrent', @@ -219,6 +223,7 @@ 'original_name': 'Overheating', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'critical_temp', 'unique_id': '500006_critical_temp', @@ -267,6 +272,7 @@ 'original_name': 'Overvoltage', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'overvoltage', 'unique_id': '500006_overvoltage', @@ -315,6 +321,7 @@ 'original_name': 'RCD error', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rcd_error', 'unique_id': '500006_rcd_error', @@ -363,6 +370,7 @@ 'original_name': 'Relay contacts welded', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'contactor_failure', 'unique_id': '500006_contactor_failure', @@ -411,6 +419,7 @@ 'original_name': 'Thermal throttling', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'overtemp', 'unique_id': '500006_overtemp', @@ -459,6 +468,7 @@ 'original_name': 'Undervoltage', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'undervoltage', 'unique_id': '500006_undervoltage', diff --git a/tests/components/lektrico/snapshots/test_button.ambr b/tests/components/lektrico/snapshots/test_button.ambr index f9cb7189237..518b96e8191 100644 --- a/tests/components/lektrico/snapshots/test_button.ambr +++ b/tests/components/lektrico/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge start', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_start', 'unique_id': '500006-charge_start', @@ -74,6 +75,7 @@ 'original_name': 'Charge stop', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_stop', 'unique_id': '500006-charge_stop', @@ -93,6 +95,54 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[button.1p7k_500006_charging_schedule_override-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.1p7k_500006_charging_schedule_override', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging schedule override', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_schedule_override', + 'unique_id': '500006-charging_schedule_override', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.1p7k_500006_charging_schedule_override-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1p7k_500006 Charging schedule override', + }), + 'context': , + 'entity_id': 'button.1p7k_500006_charging_schedule_override', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[button.1p7k_500006_restart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -121,6 +171,7 @@ 'original_name': 'Restart', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '500006-reboot', diff --git a/tests/components/lektrico/snapshots/test_number.ambr b/tests/components/lektrico/snapshots/test_number.ambr index 368479cdd06..1fe5f7613a6 100644 --- a/tests/components/lektrico/snapshots/test_number.ambr +++ b/tests/components/lektrico/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Dynamic limit', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dynamic_limit', 'unique_id': '500006_dynamic_limit', @@ -89,6 +90,7 @@ 'original_name': 'LED brightness', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led_max_brightness', 'unique_id': '500006_led_max_brightness', diff --git a/tests/components/lektrico/snapshots/test_select.ambr b/tests/components/lektrico/snapshots/test_select.ambr index 0f564abb146..e0d3cbbe755 100644 --- a/tests/components/lektrico/snapshots/test_select.ambr +++ b/tests/components/lektrico/snapshots/test_select.ambr @@ -34,6 +34,7 @@ 'original_name': 'Load balancing mode', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'load_balancing_mode', 'unique_id': '500006_load_balancing_mode', diff --git a/tests/components/lektrico/snapshots/test_sensor.ambr b/tests/components/lektrico/snapshots/test_sensor.ambr index aa146f55776..569c6af4c04 100644 --- a/tests/components/lektrico/snapshots/test_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_sensor.ambr @@ -21,12 +21,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging time', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_time', 'unique_id': '500006_charging_time', @@ -72,12 +76,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '500006_current', @@ -122,12 +130,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '500006_energy', @@ -171,12 +183,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Installation current', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'installation_current', 'unique_id': '500006_installation_current', @@ -222,12 +238,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Lifetime energy', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lifetime_energy', 'unique_id': '500006_lifetime_energy', @@ -292,6 +312,7 @@ 'original_name': 'Limit reason', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'limit_reason', 'unique_id': '500006_limit_reason', @@ -349,6 +370,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -358,6 +382,7 @@ 'original_name': 'Power', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '500006_power', @@ -377,7 +402,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0000', + 'state': '0.0', }) # --- # name: test_all_entities[sensor.1p7k_500006_state-entry] @@ -420,6 +445,7 @@ 'original_name': 'State', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state', 'unique_id': '500006_state', @@ -475,12 +501,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '500006_temperature', @@ -525,12 +555,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '500006_voltage', diff --git a/tests/components/lektrico/snapshots/test_switch.ambr b/tests/components/lektrico/snapshots/test_switch.ambr index c55e96ac9a9..71fb8b599c6 100644 --- a/tests/components/lektrico/snapshots/test_switch.ambr +++ b/tests/components/lektrico/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Authentication', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'authentication', 'unique_id': '500006_authentication', @@ -74,6 +75,7 @@ 'original_name': 'Lock', 'platform': 'lektrico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock', 'unique_id': '500006_lock', diff --git a/tests/components/lektrico/test_binary_sensor.py b/tests/components/lektrico/test_binary_sensor.py index d49eac6cc23..05947ec1cda 100644 --- a/tests/components/lektrico/test_binary_sensor.py +++ b/tests/components/lektrico/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_button.py b/tests/components/lektrico/test_button.py index 7bd77848d21..65d85ec1250 100644 --- a/tests/components/lektrico/test_button.py +++ b/tests/components/lektrico/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_init.py b/tests/components/lektrico/test_init.py index 93068ffe531..996c4fed527 100644 --- a/tests/components/lektrico/test_init.py +++ b/tests/components/lektrico/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lektrico.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_number.py b/tests/components/lektrico/test_number.py index ade6515ca72..3250ac6af91 100644 --- a/tests/components/lektrico/test_number.py +++ b/tests/components/lektrico/test_number.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_select.py b/tests/components/lektrico/test_select.py index cb09c47535e..367517c59aa 100644 --- a/tests/components/lektrico/test_select.py +++ b/tests/components/lektrico/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_sensor.py b/tests/components/lektrico/test_sensor.py index 27be7ff1c11..d3c6d464b9b 100644 --- a/tests/components/lektrico/test_sensor.py +++ b/tests/components/lektrico/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_switch.py b/tests/components/lektrico/test_switch.py index cfa693d9e44..6b038a250b4 100644 --- a/tests/components/lektrico/test_switch.py +++ b/tests/components/lektrico/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/letpot/snapshots/test_binary_sensor.ambr b/tests/components/letpot/snapshots/test_binary_sensor.ambr index 121cf4e3f82..64596ffcd4b 100644 --- a/tests/components/letpot/snapshots/test_binary_sensor.ambr +++ b/tests/components/letpot/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Low water', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'low_water', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_low_water', @@ -75,6 +76,7 @@ 'original_name': 'Pump', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pump', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump', @@ -123,6 +125,7 @@ 'original_name': 'Pump error', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pump_error', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump_error', @@ -171,6 +174,7 @@ 'original_name': 'Low nutrients', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'low_nutrients', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_nutrients', @@ -219,6 +223,7 @@ 'original_name': 'Low water', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'low_water', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_water', @@ -267,6 +272,7 @@ 'original_name': 'Pump', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pump', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump', @@ -315,6 +321,7 @@ 'original_name': 'Refill error', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'refill_error', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_refill_error', diff --git a/tests/components/letpot/snapshots/test_sensor.ambr b/tests/components/letpot/snapshots/test_sensor.ambr index 5d123cf6ce0..12669bb4110 100644 --- a/tests/components/letpot/snapshots/test_sensor.ambr +++ b/tests/components/letpot/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_temperature', @@ -81,6 +85,7 @@ 'original_name': 'Water level', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_level', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_water_level', diff --git a/tests/components/letpot/snapshots/test_switch.ambr b/tests/components/letpot/snapshots/test_switch.ambr index 1a36e555dd1..d76f943ccaa 100644 --- a/tests/components/letpot/snapshots/test_switch.ambr +++ b/tests/components/letpot/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Alarm sound', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_sound', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_alarm_sound', @@ -74,6 +75,7 @@ 'original_name': 'Auto mode', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_mode', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_auto_mode', @@ -121,6 +123,7 @@ 'original_name': 'Power', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_power', @@ -168,6 +171,7 @@ 'original_name': 'Pump cycling', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pump_cycling', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump_cycling', diff --git a/tests/components/letpot/snapshots/test_time.ambr b/tests/components/letpot/snapshots/test_time.ambr index 9ca75003e56..8c3ba0c8c08 100644 --- a/tests/components/letpot/snapshots/test_time.ambr +++ b/tests/components/letpot/snapshots/test_time.ambr @@ -27,6 +27,7 @@ 'original_name': 'Light off', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_schedule_end', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_schedule_end', @@ -74,6 +75,7 @@ 'original_name': 'Light on', 'platform': 'letpot', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_schedule_start', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_schedule_start', diff --git a/tests/components/letpot/test_binary_sensor.py b/tests/components/letpot/test_binary_sensor.py index 03ce1bee1a5..43565914072 100644 --- a/tests/components/letpot/test_binary_sensor.py +++ b/tests/components/letpot/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/letpot/test_sensor.py b/tests/components/letpot/test_sensor.py index a527d062ca7..3ed4c6d9308 100644 --- a/tests/components/letpot/test_sensor.py +++ b/tests/components/letpot/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index 0ba1f556bc9..7eeafd78291 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch from letpot.exceptions import LetPotConnectionException, LetPotException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( SERVICE_TOGGLE, diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index e65ea4532e1..dba51ce8497 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from letpot.exceptions import LetPotConnectionException, LetPotException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.time import SERVICE_SET_VALUE from homeassistant.const import Platform diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index 111d49a2ef3..fd1b31e80bf 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -52,6 +52,7 @@ 'original_name': None, 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', diff --git a/tests/components/lg_thinq/snapshots/test_event.ambr b/tests/components/lg_thinq/snapshots/test_event.ambr index dbb43ce0bb9..670ce8985fa 100644 --- a/tests/components/lg_thinq/snapshots/test_event.ambr +++ b/tests/components/lg_thinq/snapshots/test_event.ambr @@ -31,6 +31,7 @@ 'original_name': 'Notification', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_notification', diff --git a/tests/components/lg_thinq/snapshots/test_number.ambr b/tests/components/lg_thinq/snapshots/test_number.ambr index ef4d9a21b86..5fa03b60033 100644 --- a/tests/components/lg_thinq/snapshots/test_number.ambr +++ b/tests/components/lg_thinq/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Schedule turn-off', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_stop', @@ -89,6 +90,7 @@ 'original_name': 'Schedule turn-on', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_start', diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 5e6eb98ac42..d561c4c6fc9 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Filter remaining', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_filter_lifetime', @@ -77,6 +78,7 @@ 'original_name': 'Humidity', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_humidity', @@ -129,6 +131,7 @@ 'original_name': 'PM1', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm1', @@ -181,6 +184,7 @@ 'original_name': 'PM10', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm10', @@ -233,6 +237,7 @@ 'original_name': 'PM2.5', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm2', @@ -277,12 +282,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Schedule turn-off', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', @@ -326,12 +335,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Schedule turn-on', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', @@ -381,6 +394,7 @@ 'original_name': 'Schedule turn-on', 'platform': 'lg_thinq', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_absolute_to_start', diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index e53b1c5ff39..c79331dd638 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lg_thinq/test_event.py b/tests/components/lg_thinq/test_event.py index bea758cb943..398af1e8aad 100644 --- a/tests/components/lg_thinq/test_event.py +++ b/tests/components/lg_thinq/test_event.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lg_thinq/test_number.py b/tests/components/lg_thinq/test_number.py index e578e4eba7a..7c37ba3f5e0 100644 --- a/tests/components/lg_thinq/test_number.py +++ b/tests/components/lg_thinq/test_number.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index e1f1a7ed93d..e2c8e122eea 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/linear_garage_door/snapshots/test_cover.ambr b/tests/components/linear_garage_door/snapshots/test_cover.ambr index a09156c53e0..dc3df6684bc 100644 --- a/tests/components/linear_garage_door/snapshots/test_cover.ambr +++ b/tests/components/linear_garage_door/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'linear_garage_door', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'test1-GDO', @@ -76,6 +77,7 @@ 'original_name': None, 'platform': 'linear_garage_door', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'test2-GDO', @@ -125,6 +127,7 @@ 'original_name': None, 'platform': 'linear_garage_door', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'test3-GDO', @@ -174,6 +177,7 @@ 'original_name': None, 'platform': 'linear_garage_door', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'test4-GDO', diff --git a/tests/components/linear_garage_door/snapshots/test_light.ambr b/tests/components/linear_garage_door/snapshots/test_light.ambr index 9e27efc02ec..930d78d4706 100644 --- a/tests/components/linear_garage_door/snapshots/test_light.ambr +++ b/tests/components/linear_garage_door/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': 'Light', 'platform': 'linear_garage_door', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'test1-Light', @@ -88,6 +89,7 @@ 'original_name': 'Light', 'platform': 'linear_garage_door', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'test2-Light', @@ -145,6 +147,7 @@ 'original_name': 'Light', 'platform': 'linear_garage_door', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'test3-Light', @@ -202,6 +205,7 @@ 'original_name': 'Light', 'platform': 'linear_garage_door', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'test4-Light', diff --git a/tests/components/linear_garage_door/test_cover.py b/tests/components/linear_garage_door/test_cover.py index be5ae8f35f7..caa590f3b3a 100644 --- a/tests/components/linear_garage_door/test_cover.py +++ b/tests/components/linear_garage_door/test_cover.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, diff --git a/tests/components/linear_garage_door/test_diagnostics.py b/tests/components/linear_garage_door/test_diagnostics.py index a00feed43ff..f51bb0a366c 100644 --- a/tests/components/linear_garage_door/test_diagnostics.py +++ b/tests/components/linear_garage_door/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/linear_garage_door/test_light.py b/tests/components/linear_garage_door/test_light.py index 351ddad813a..d462130dc91 100644 --- a/tests/components/linear_garage_door/test_light.py +++ b/tests/components/linear_garage_door/test_light.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, diff --git a/tests/components/linkplay/test_config_flow.py b/tests/components/linkplay/test_config_flow.py index adf6aa601ae..8c0dd4af88b 100644 --- a/tests/components/linkplay/test_config_flow.py +++ b/tests/components/linkplay/test_config_flow.py @@ -220,3 +220,28 @@ async def test_user_flow_errors( CONF_HOST: HOST, } assert result["result"].unique_id == UUID + + +@pytest.mark.usefixtures("mock_linkplay_factory_bridge") +async def test_zeroconf_no_probe_existing_device( + hass: HomeAssistant, mock_linkplay_factory_bridge: AsyncMock +) -> None: + """Test we do not probe the device is the host is already configured.""" + entry = MockConfigEntry( + data={CONF_HOST: HOST}, + domain=DOMAIN, + title=NAME, + unique_id=UUID, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_linkplay_factory_bridge.mock_calls) == 0 diff --git a/tests/components/linkplay/test_diagnostics.py b/tests/components/linkplay/test_diagnostics.py index de60b7ecb3a..332359b9769 100644 --- a/tests/components/linkplay/test_diagnostics.py +++ b/tests/components/linkplay/test_diagnostics.py @@ -5,7 +5,7 @@ from unittest.mock import patch from linkplay.bridge import LinkPlayMultiroom from linkplay.consts import API_ENDPOINT from linkplay.endpoint import LinkPlayApiEndpoint -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.linkplay.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/lirc/__init__.py b/tests/components/lirc/__init__.py new file mode 100644 index 00000000000..f8e11b194a6 --- /dev/null +++ b/tests/components/lirc/__init__.py @@ -0,0 +1 @@ +"""LIRC tests.""" diff --git a/tests/components/lirc/test_init.py b/tests/components/lirc/test_init.py new file mode 100644 index 00000000000..d6fd7975c77 --- /dev/null +++ b/tests/components/lirc/test_init.py @@ -0,0 +1,31 @@ +"""Tests for the LIRC.""" + +from unittest.mock import Mock, patch + +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", lirc=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + from homeassistant.components.lirc import ( # pylint: disable=import-outside-toplevel + DOMAIN as LIRC_DOMAIN, + ) + + assert await async_setup_component( + hass, + LIRC_DOMAIN, + { + LIRC_DOMAIN: {}, + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{LIRC_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index e42bdb048b7..9ba4acaa935 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -37,7 +37,7 @@ async def test_unload_entry(hass: HomeAssistant, mock_account: MagicMock) -> Non {ATTR_ENTITY_ID: VACUUM_ENTITY_ID}, blocking=True, ) - getattr(mock_account.robots[0], "start_cleaning").assert_called_once() + mock_account.robots[0].start_cleaning.assert_called_once() assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/madvr/snapshots/test_binary_sensor.ambr b/tests/components/madvr/snapshots/test_binary_sensor.ambr index 7d665210a6f..8f82914ae25 100644 --- a/tests/components/madvr/snapshots/test_binary_sensor.ambr +++ b/tests/components/madvr/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'HDR flag', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hdr_flag', 'unique_id': '00:11:22:33:44:55_hdr_flag', @@ -74,6 +75,7 @@ 'original_name': 'Outgoing HDR flag', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outgoing_hdr_flag', 'unique_id': '00:11:22:33:44:55_outgoing_hdr_flag', @@ -121,6 +123,7 @@ 'original_name': 'Power state', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_state', 'unique_id': '00:11:22:33:44:55_power_state', @@ -168,6 +171,7 @@ 'original_name': 'Signal state', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'signal_state', 'unique_id': '00:11:22:33:44:55_signal_state', diff --git a/tests/components/madvr/snapshots/test_remote.ambr b/tests/components/madvr/snapshots/test_remote.ambr index c90270674c8..876fa81ed0c 100644 --- a/tests/components/madvr/snapshots/test_remote.ambr +++ b/tests/components/madvr/snapshots/test_remote.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:11:22:33:44:55', diff --git a/tests/components/madvr/snapshots/test_sensor.ambr b/tests/components/madvr/snapshots/test_sensor.ambr index 115f6a3f5d7..c6c680260d3 100644 --- a/tests/components/madvr/snapshots/test_sensor.ambr +++ b/tests/components/madvr/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Aspect decimal', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'aspect_dec', 'unique_id': '00:11:22:33:44:55_aspect_dec', @@ -74,6 +75,7 @@ 'original_name': 'Aspect integer', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'aspect_int', 'unique_id': '00:11:22:33:44:55_aspect_int', @@ -121,6 +123,7 @@ 'original_name': 'Aspect name', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'aspect_name', 'unique_id': '00:11:22:33:44:55_aspect_name', @@ -168,6 +171,7 @@ 'original_name': 'Aspect resolution', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'aspect_res', 'unique_id': '00:11:22:33:44:55_aspect_res', @@ -211,12 +215,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'CPU temperature', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_cpu', 'unique_id': '00:11:22:33:44:55_temp_cpu', @@ -263,12 +271,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'GPU temperature', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_gpu', 'unique_id': '00:11:22:33:44:55_temp_gpu', @@ -315,12 +327,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'HDMI temperature', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_hdmi', 'unique_id': '00:11:22:33:44:55_temp_hdmi', @@ -376,6 +392,7 @@ 'original_name': 'Incoming aspect ratio', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'incoming_aspect_ratio', 'unique_id': '00:11:22:33:44:55_incoming_aspect_ratio', @@ -434,6 +451,7 @@ 'original_name': 'Incoming bit depth', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'incoming_bit_depth', 'unique_id': '00:11:22:33:44:55_incoming_bit_depth', @@ -492,6 +510,7 @@ 'original_name': 'Incoming black levels', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'incoming_black_levels', 'unique_id': '00:11:22:33:44:55_incoming_black_levels', @@ -551,6 +570,7 @@ 'original_name': 'Incoming color space', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'incoming_color_space', 'unique_id': '00:11:22:33:44:55_incoming_color_space', @@ -615,6 +635,7 @@ 'original_name': 'Incoming colorimetry', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'incoming_colorimetry', 'unique_id': '00:11:22:33:44:55_incoming_colorimetry', @@ -672,6 +693,7 @@ 'original_name': 'Incoming frame rate', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'incoming_frame_rate', 'unique_id': '00:11:22:33:44:55_incoming_frame_rate', @@ -719,6 +741,7 @@ 'original_name': 'Incoming resolution', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'incoming_res', 'unique_id': '00:11:22:33:44:55_incoming_res', @@ -771,6 +794,7 @@ 'original_name': 'Incoming signal type', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'incoming_signal_type', 'unique_id': '00:11:22:33:44:55_incoming_signal_type', @@ -819,12 +843,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mainboard temperature', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_mainboard', 'unique_id': '00:11:22:33:44:55_temp_mainboard', @@ -875,6 +903,7 @@ 'original_name': 'Masking decimal', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'masking_dec', 'unique_id': '00:11:22:33:44:55_masking_dec', @@ -922,6 +951,7 @@ 'original_name': 'Masking integer', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'masking_int', 'unique_id': '00:11:22:33:44:55_masking_int', @@ -969,6 +999,7 @@ 'original_name': 'Masking resolution', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'masking_res', 'unique_id': '00:11:22:33:44:55_masking_res', @@ -1022,6 +1053,7 @@ 'original_name': 'Outgoing bit depth', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outgoing_bit_depth', 'unique_id': '00:11:22:33:44:55_outgoing_bit_depth', @@ -1080,6 +1112,7 @@ 'original_name': 'Outgoing black levels', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outgoing_black_levels', 'unique_id': '00:11:22:33:44:55_outgoing_black_levels', @@ -1139,6 +1172,7 @@ 'original_name': 'Outgoing color space', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outgoing_color_space', 'unique_id': '00:11:22:33:44:55_outgoing_color_space', @@ -1203,6 +1237,7 @@ 'original_name': 'Outgoing colorimetry', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outgoing_colorimetry', 'unique_id': '00:11:22:33:44:55_outgoing_colorimetry', @@ -1260,6 +1295,7 @@ 'original_name': 'Outgoing frame rate', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outgoing_frame_rate', 'unique_id': '00:11:22:33:44:55_outgoing_frame_rate', @@ -1307,6 +1343,7 @@ 'original_name': 'Outgoing resolution', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outgoing_res', 'unique_id': '00:11:22:33:44:55_outgoing_res', @@ -1359,6 +1396,7 @@ 'original_name': 'Outgoing signal type', 'platform': 'madvr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outgoing_signal_type', 'unique_id': '00:11:22:33:44:55_outgoing_signal_type', diff --git a/tests/components/madvr/test_binary_sensor.py b/tests/components/madvr/test_binary_sensor.py index 9ddbc7b3afe..6db0471b338 100644 --- a/tests/components/madvr/test_binary_sensor.py +++ b/tests/components/madvr/test_binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/madvr/test_diagnostics.py b/tests/components/madvr/test_diagnostics.py index 453eaba8d94..4e355e82612 100644 --- a/tests/components/madvr/test_diagnostics.py +++ b/tests/components/madvr/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.const import Platform diff --git a/tests/components/madvr/test_remote.py b/tests/components/madvr/test_remote.py index 1ddbacdb6e9..e91c206bdd5 100644 --- a/tests/components/madvr/test_remote.py +++ b/tests/components/madvr/test_remote.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.remote import ( DOMAIN as REMOTE_DOMAIN, diff --git a/tests/components/madvr/test_sensor.py b/tests/components/madvr/test_sensor.py index dd1722913f2..029f32d552d 100644 --- a/tests/components/madvr/test_sensor.py +++ b/tests/components/madvr/test_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.madvr.sensor import get_temperature from homeassistant.const import STATE_UNKNOWN, Platform diff --git a/tests/components/mastodon/snapshots/test_sensor.ambr b/tests/components/mastodon/snapshots/test_sensor.ambr index 40986210454..db84517b33d 100644 --- a/tests/components/mastodon/snapshots/test_sensor.ambr +++ b/tests/components/mastodon/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Followers', 'platform': 'mastodon', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'followers', 'unique_id': 'trwnh_mastodon_social_followers', @@ -80,6 +81,7 @@ 'original_name': 'Following', 'platform': 'mastodon', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'following', 'unique_id': 'trwnh_mastodon_social_following', @@ -131,6 +133,7 @@ 'original_name': 'Posts', 'platform': 'mastodon', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'posts', 'unique_id': 'trwnh_mastodon_social_posts', diff --git a/tests/components/mastodon/test_diagnostics.py b/tests/components/mastodon/test_diagnostics.py index c2de15d1a51..531543ee65d 100644 --- a/tests/components/mastodon/test_diagnostics.py +++ b/tests/components/mastodon/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index 519b4c4027d..18c4760e473 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode from matter_server.common.helpers.util import dataclass_from_dict from matter_server.common.models import EventType, MatterNodeData -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index e180b9e9363..7da9a28484e 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -43,6 +43,7 @@ async def matter_client_fixture() -> AsyncGenerator[MagicMock]: pytest.fail("Listen was not cancelled!") client.connect = AsyncMock(side_effect=connect) + client.check_node_update = AsyncMock(return_value=None) client.start_listening = AsyncMock(side_effect=listen) client.server_info = ServerInfoMessage( fabric_id=MOCK_FABRIC_ID, @@ -76,6 +77,7 @@ async def integration_fixture( "air_purifier", "air_quality_sensor", "color_temperature_light", + "cooktop", "dimmable_light", "dimmable_plugin_unit", "door_lock", @@ -91,9 +93,11 @@ async def integration_fixture( "generic_switch", "generic_switch_multi", "humidity_sensor", + "laundry_dryer", "leak_sensor", "light_sensor", "microwave_oven", + "mounted_dimmable_load_control_fixture", "multi_endpoint_light", "occupancy_sensor", "on_off_plugin_unit", @@ -101,13 +105,17 @@ async def integration_fixture( "onoff_light_alt_name", "onoff_light_no_name", "onoff_light_with_levelcontrol_present", + "oven", "pressure_sensor", + "pump", "room_airconditioner", "silabs_dishwasher", "silabs_evse_charging", "silabs_laundrywasher", + "silabs_refrigerator", "silabs_water_heater", "smoke_detector", + "solar_power", "switch_unit", "temperature_sensor", "thermostat", diff --git a/tests/components/matter/fixtures/nodes/cooktop.json b/tests/components/matter/fixtures/nodes/cooktop.json new file mode 100644 index 00000000000..f32322b6cb7 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/cooktop.json @@ -0,0 +1,308 @@ +{ + "node_id": 3, + "date_commissioned": "2025-04-29T15:54:11.963738", + "last_interview": "2025-04-29T15:54:11.963750", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 43, 45, 48, 49, 51, 54, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1, 2], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 1, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "Mock", + "0/40/2": 65521, + "0/40/3": "Mock Cooktop", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "8854D258EF79CBAE", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 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, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, + 22, 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/45/0": 0, + "0/45/1": [0, 1, 2], + "0/45/65532": 1, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/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/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkIN/v6b", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZAP/YMcX0yMLQ==", + "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": 23, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65532, 65533, 65528, 65529, 65531 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65532, 65533, 65528, 65529, + 65531 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRAxgkBwEkCAEwCUEE1B4lA2AYRzpeBC9EizUv1FilsHNIEbFdH0c0o1NCiMMsdkxMJ/MnyXholb/76NUBLrq0tFMXYMa8TjIcHh915zcKNQEoARgkAgE2AwQCBAEYMAQUgfoxJi2HOriuKa6K2cbtp49/SYIwBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0DCxbisQiHwqDX9s2aGsCUz+6/8evG3EOMGOU0tG1DuXY4kd5TTxmIAjk51GwIszElOMBsfQV5ZAB1KbSKgaUrwGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 3, + "5": "Maison", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/6/0": true, + "1/6/65532": 4, + "1/6/65533": 6, + "1/6/65528": [], + "1/6/65529": [0], + "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 120, + "1": 1 + } + ], + "1/29/1": [3, 6, 29], + "1/29/2": [], + "1/29/3": [2], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "2/6/0": true, + "2/6/65532": 4, + "2/6/65533": 6, + "2/6/65528": [], + "2/6/65529": [0], + "2/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "2/29/0": [ + { + "0": 119, + "1": 1 + } + ], + "2/29/1": [6, 29, 86, 1026], + "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, 65532, 65533, 65528, 65529, 65531], + "2/86/4": 1, + "2/86/5": ["Low", "Medium", "High"], + "2/86/65532": 2, + "2/86/65533": 1, + "2/86/65528": [], + "2/86/65529": [0], + "2/86/65531": [4, 5, 65532, 65533, 65528, 65529, 65531], + "2/1026/0": 18000, + "2/1026/1": null, + "2/1026/2": null, + "2/1026/65532": 0, + "2/1026/65533": 4, + "2/1026/65528": [], + "2/1026/65529": [], + "2/1026/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/laundry_dryer.json b/tests/components/matter/fixtures/nodes/laundry_dryer.json new file mode 100644 index 00000000000..a74bca934a0 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/laundry_dryer.json @@ -0,0 +1,307 @@ +{ + "node_id": 8, + "date_commissioned": "2025-05-01T11:45:46.203438", + "last_interview": "2025-05-01T11:45:46.203452", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 45, 48, 49, 51, 54, 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, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/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, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Laundrydryer", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "8A7EFAF22659A7C6", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, + 22, 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/45/0": 0, + "0/45/65532": 1, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/2": 0, + "0/49/3": 0, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkIH8Iu2", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZD1j4HmibD6Yw==", + "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": 11, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65532, 65533, 65528, 65529, 65531 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65532, 65533, 65528, 65529, + 65531 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRCBgkBwEkCAEwCUEEuBSQYARV1MtZ/zTYCZDFAchE6gYPl8EQsnZ/zBOFY/+CRpZdiSIJdKySB6kixHqnFG5AlLLuN0kV2p3RgtFNhDcKNQEoARgkAgE2AwQCBAEYMAQUHBnbZ0B6X2b4Hrmm7ND49lbGb4MwBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0AYHLmEMzw4m5K4nFJO6x8PB5xwkHJ0QtPgowB2/HYdTyR+MIPJRQfiPZB2WSzaDQpkMj+niAV9X59mKSwTntitGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 8, + "5": "ha-freebox", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 6, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/6/0": false, + "1/6/65532": 2, + "1/6/65533": 6, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 124, + "1": 1 + } + ], + "1/29/1": [3, 6, 29, 86, 96], + "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, 65532, 65533, 65528, 65529, 65531], + "1/86/4": 0, + "1/86/5": ["Low", "Medium", "High"], + "1/86/65532": 2, + "1/86/65533": 1, + "1/86/65528": [], + "1/86/65529": [0], + "1/86/65531": [4, 5, 65532, 65533, 65528, 65529, 65531], + "1/96/0": ["pre-soak", "rinse", "spin"], + "1/96/1": 0, + "1/96/2": null, + "1/96/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 2 + }, + { + "0": 3 + } + ], + "1/96/4": 1, + "1/96/5": { + "0": 0 + }, + "1/96/65532": 0, + "1/96/65533": 3, + "1/96/65528": [4], + "1/96/65529": [0, 1, 2, 3], + "1/96/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/microwave_oven.json b/tests/components/matter/fixtures/nodes/microwave_oven.json index ed0a4accd6a..bbba8b12e25 100644 --- a/tests/components/matter/fixtures/nodes/microwave_oven.json +++ b/tests/components/matter/fixtures/nodes/microwave_oven.json @@ -368,6 +368,8 @@ "1/95/3": 20, "1/95/4": 90, "1/95/5": 10, + "1/95/6": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000], + "1/95/7": 9, "1/95/8": 1000, "1/95/65532": 5, "1/95/65533": 1, @@ -395,7 +397,7 @@ "1/96/5": { "0": 0 }, - "1/96/65532": 0, + "1/96/65532": 2, "1/96/65533": 2, "1/96/65528": [4], "1/96/65529": [0, 1, 2, 3], diff --git a/tests/components/matter/fixtures/nodes/mounted_dimmable_load_control_fixture.json b/tests/components/matter/fixtures/nodes/mounted_dimmable_load_control_fixture.json new file mode 100644 index 00000000000..b19b97bc41c --- /dev/null +++ b/tests/components/matter/fixtures/nodes/mounted_dimmable_load_control_fixture.json @@ -0,0 +1,308 @@ +{ + "node_id": 14, + "date_commissioned": "2025-05-02T08:15:29.450054", + "last_interview": "2025-05-02T08:15:29.450072", + "interview_version": 6, + "available": false, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 43, 48, 49, 50, 51, 52, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Mounted dimmable load control", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/18": "53AB7717C13D0DD2", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/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/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkK7ybsD", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZBQ8P5SEgahQg==", + "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": 13, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65532, 65533, 65528, 65529, 65531 + ], + "0/52/0": [ + { + "0": 2673, + "1": "2673" + }, + { + "0": 2672, + "1": "2672" + }, + { + "0": 2671, + "1": "2671" + }, + { + "0": 2670, + "1": "2670" + }, + { + "0": 2669, + "1": "2669" + }, + { + "0": 2668, + "1": "2668" + }, + { + "0": 2667, + "1": "2667" + } + ], + "0/52/1": 830464, + "0/52/2": 635904, + "0/52/3": 635904, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRDhgkBwEkCAEwCUEEdWlSeMU0X1DnfNwpCgYjMQOf/XgYW1AbAJCiYwSvbm6/9kZ1C97E9ah0h3vtKD4jZIQBDQGv3e1ffCuw2OlDuTcKNQEoARgkAgE2AwQCBAEYMAQUP1MVmuztpdJEPcw9p/9X9qok6iAwBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0BAw6CB9ukgfW1LKZHsr2h6G2JAQWjUPNaWQrFAgWA7GAbgY2wdsppjUJ6kXIOyO5Ci/vlQHI2NE6woRbS+6QOuGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 14, + "5": "ha-freebox", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 6, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 1, + "1/6/65533": 6, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65532, 65533, 65528, 65529, 65531 + ], + "1/8/0": 254, + "1/8/1": 0, + "1/8/15": 0, + "1/8/17": 0, + "1/8/16384": 0, + "1/8/65532": 3, + "1/8/65533": 6, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [0, 1, 15, 17, 16384, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 272, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29], + "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, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/oven.json b/tests/components/matter/fixtures/nodes/oven.json new file mode 100644 index 00000000000..6e325146f83 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/oven.json @@ -0,0 +1,484 @@ +{ + "node_id": 2, + "date_commissioned": "2025-04-29T15:37:55.171819", + "last_interview": "2025-04-29T15:37:55.171832", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 43, 45, 48, 49, 51, 54, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1, 2, 3, 4], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 1, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Oven", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "EB38EF759DAA4DB8", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, + 22, 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/45/0": 0, + "0/45/65532": 1, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/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/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkIN/v6b", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZAP/YMcX0yMLQ==", + "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": 26, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65532, 65533, 65528, 65529, 65531 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65532, 65533, 65528, 65529, + 65531 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRAhgkBwEkCAEwCUEE3mWlRgzQdFFY8sclYjEv0uyAYGfTqVozOb5xR/ypUesqyIwaR1bqY6K4D2+zUx+FBvbRBBUj0PBwJ32cvUm+LTcKNQEoARgkAgE2AwQCBAEYMAQUnKark4iAc32+X9hGHNDon32qhdowBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0ABtt37m0318llNw7RtRoGFeHD4OxuGHNRS7JT28Oy0H4dNXb4Nu+xyQEK5zVri/QSUK3doq/PD8G0h33Ix4oOLGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 2, + "5": "Maison", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 6, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 123, + "1": 2 + } + ], + "1/29/1": [3, 29], + "1/29/2": [], + "1/29/3": [2, 3], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "2/29/0": [ + { + "0": 113, + "1": 3 + } + ], + "2/29/1": [29, 72, 73, 86, 1026], + "2/29/2": [], + "2/29/3": [], + "2/29/4": [ + { + "0": null, + "1": 8, + "2": 2 + } + ], + "2/29/65532": 1, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "2/72/0": ["pre-heating", "pre-heated", "cooling down"], + "2/72/1": 0, + "2/72/2": null, + "2/72/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 3 + } + ], + "2/72/4": 1, + "2/72/5": { + "0": 0 + }, + "2/72/65532": 0, + "2/72/65533": 2, + "2/72/65528": [4], + "2/72/65529": [1, 2], + "2/72/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], + "2/73/0": [ + { + "0": "Bake", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Convection", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Grill", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + }, + { + "0": "Roast", + "1": 3, + "2": [ + { + "1": 16387 + } + ] + }, + { + "0": "Clean", + "1": 4, + "2": [ + { + "1": 16388 + } + ] + }, + { + "0": "Convection Bake", + "1": 5, + "2": [ + { + "1": 16389 + } + ] + }, + { + "0": "Convection Roast", + "1": 6, + "2": [ + { + "1": 16390 + } + ] + }, + { + "0": "Warming", + "1": 7, + "2": [ + { + "1": 16391 + } + ] + }, + { + "0": "Proofing", + "1": 8, + "2": [ + { + "1": 16392 + } + ] + } + ], + "2/73/1": 0, + "2/73/65532": 0, + "2/73/65533": 2, + "2/73/65528": [1], + "2/73/65529": [0], + "2/73/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "2/86/0": 7600, + "2/86/1": 7600, + "2/86/2": 28800, + "2/86/65532": 1, + "2/86/65533": 1, + "2/86/65528": [], + "2/86/65529": [0], + "2/86/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "2/1026/0": 6555, + "2/1026/1": 3000, + "2/1026/2": 30000, + "2/1026/65532": 0, + "2/1026/65533": 4, + "2/1026/65528": [], + "2/1026/65529": [], + "2/1026/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "3/3/0": 0, + "3/3/1": 0, + "3/3/65532": 0, + "3/3/65533": 6, + "3/3/65528": [], + "3/3/65529": [0], + "3/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "3/6/0": false, + "3/6/65532": 4, + "3/6/65533": 6, + "3/6/65528": [], + "3/6/65529": [0], + "3/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "3/29/0": [ + { + "0": 120, + "1": 1 + } + ], + "3/29/1": [3, 6, 29], + "3/29/2": [], + "3/29/3": [4], + "3/29/65532": 0, + "3/29/65533": 2, + "3/29/65528": [], + "3/29/65529": [], + "3/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "4/6/0": false, + "4/6/65532": 4, + "4/6/65533": 6, + "4/6/65528": [], + "4/6/65529": [0], + "4/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "4/29/0": [ + { + "0": 119, + "1": 1 + } + ], + "4/29/1": [6, 29, 86, 1026], + "4/29/2": [], + "4/29/3": [], + "4/29/4": [ + { + "0": null, + "1": 8, + "2": 0 + } + ], + "4/29/65532": 1, + "4/29/65533": 2, + "4/29/65528": [], + "4/29/65529": [], + "4/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "4/86/4": 0, + "4/86/5": ["Low", "Medium", "High"], + "4/86/65532": 2, + "4/86/65533": 1, + "4/86/65528": [], + "4/86/65529": [0], + "4/86/65531": [4, 5, 65532, 65533, 65528, 65529, 65531], + "4/1026/0": 0, + "4/1026/1": null, + "4/1026/2": null, + "4/1026/65532": 0, + "4/1026/65533": 4, + "4/1026/65528": [], + "4/1026/65529": [], + "4/1026/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/pump.json b/tests/components/matter/fixtures/nodes/pump.json new file mode 100644 index 00000000000..e4afc0b4f33 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/pump.json @@ -0,0 +1,271 @@ +{ + "node_id": 3, + "date_commissioned": "2025-05-09T15:45:16.457511", + "last_interview": "2025-05-09T15:49:41.414681", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 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, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/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, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Pump", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/18": "C7C87250EABB7BC8", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 18, 19, 21, 22, 24, 65532, 65533, 65528, + 65529, 65531 + ], + "0/45/65532": 0, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/2": 0, + "0/49/3": 0, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkLWHXRl", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZARgk66TFlR1w==", + "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": 282, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65532, 65533, 65528, 65529, 65531], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRAxgkBwEkCAEwCUEE3Z+JMyIjVAtmzqwEaVxp1V6SNzKfmJT0691W905Zr2Sv2fSCu0OMmvZAt1ih58GZj9MTRYM4Up3sJF481rks+zcKNQEoARgkAgE2AwQCBAEYMAQUjivV8lU5bIctgqrN/Mb2xBPB6XwwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0CrPeCxivaBtn7q7Pcj7JvVWdN2JAZ+lVlL08Uix9hjOCShJntfL6j+LFRKPQ1elgp2E3DO/jvkSAEFmAzXp8zOGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", + "2": 4939, + "3": 2, + "4": 3, + "5": "ha-freebox", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "0/62/65528": [1, 3, 5, 8, 14], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], + "0/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, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 6, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/6/0": true, + "1/6/65532": 0, + "1/6/65533": 5, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/8/0": 254, + "1/8/15": 0, + "1/8/17": 0, + "1/8/65532": 0, + "1/8/65533": 6, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [0, 15, 17, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 771, + "1": 1 + } + ], + "1/29/1": [3, 6, 8, 29, 512, 1026, 1027, 1028], + "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, 65532, 65533, 65528, 65529, 65531], + "1/512/0": 32767, + "1/512/1": 65534, + "1/512/2": 65534, + "1/512/16": 32, + "1/512/17": 0, + "1/512/18": 5, + "1/512/19": null, + "1/512/20": 1000, + "1/512/32": 0, + "1/512/33": 5, + "1/512/65532": 0, + "1/512/65533": 6, + "1/512/65528": [], + "1/512/65529": [], + "1/512/65531": [ + 0, 1, 2, 16, 17, 18, 19, 20, 32, 33, 65532, 65533, 65528, 65529, 65531 + ], + "1/1026/0": 6000, + "1/1026/1": -27315, + "1/1026/2": 32767, + "1/1026/65532": 0, + "1/1026/65533": 6, + "1/1026/65528": [], + "1/1026/65529": [], + "1/1026/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "1/1027/0": 100, + "1/1027/1": -32767, + "1/1027/2": 32767, + "1/1027/65532": 0, + "1/1027/65533": 6, + "1/1027/65528": [], + "1/1027/65529": [], + "1/1027/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "1/1028/0": 50, + "1/1028/1": 0, + "1/1028/2": 65534, + "1/1028/65532": 0, + "1/1028/65533": 6, + "1/1028/65528": [], + "1/1028/65529": [], + "1/1028/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/silabs_refrigerator.json b/tests/components/matter/fixtures/nodes/silabs_refrigerator.json new file mode 100644 index 00000000000..e4e04ac6ca1 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_refrigerator.json @@ -0,0 +1,534 @@ +{ + "node_id": 58, + "date_commissioned": "2024-12-23T10:42:11.104085", + "last_interview": "2024-12-23T10:42:11.104098", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1, 2, 3], + "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/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, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "Refrigerator", + "0/40/4": 32782, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "3F67EB015C2A0D0E", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039360, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, + 22, 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/9": 10, + "0/49/10": 5, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/51/0": [ + { + "0": "MyHome36", + "1": true, + "2": null, + "3": null, + "4": "spIfNquw4AU=", + "5": [], + "6": [ + "/U8h7+VkAADWDI9VgtWoMw==", + "/QANuACgAAAAAAD//gBEbQ==", + "/QANuACgAACT8m5dNLdrXA==", + "/oAAAAAAAACwkh82q7DgBQ==" + ], + "7": 4 + } + ], + "0/51/1": 1, + "0/51/2": 141, + "0/51/3": 0, + "0/51/4": 6, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/53/0": 25, + "0/53/1": 4, + "0/53/2": "MyHome36", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "QP0ADbgAoAAA", + "0/53/7": [ + { + "0": 4222415899952472931, + "1": 8, + "2": 24576, + "3": 151026, + "4": 21588, + "5": 3, + "6": -71, + "7": -71, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17459145101989614194, + "1": 3, + "2": 26624, + "3": 485082, + "4": 21597, + "5": 3, + "6": -38, + "7": -39, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8241705229565301122, + "1": 18, + "2": 57344, + "3": 276088, + "4": 22218, + "5": 3, + "6": -52, + "7": -47, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 0, + "1": 3072, + "2": 3, + "3": 17, + "4": 2, + "5": 0, + "6": 0, + "7": 142, + "8": true, + "9": false + }, + { + "0": 0, + "1": 17408, + "2": 17, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 142, + "8": true, + "9": false + }, + { + "0": 4222415899952472931, + "1": 24576, + "2": 24, + "3": 17, + "4": 1, + "5": 3, + "6": 2, + "7": 9, + "8": true, + "9": true + }, + { + "0": 17459145101989614194, + "1": 26624, + "2": 26, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 3, + "8": true, + "9": true + }, + { + "0": 0, + "1": 41984, + "2": 41, + "3": 17, + "4": 1, + "5": 0, + "6": 0, + "7": 25, + "8": true, + "9": false + }, + { + "0": 0, + "1": 43008, + "2": 42, + "3": 17, + "4": 2, + "5": 0, + "6": 0, + "7": 44, + "8": true, + "9": false + }, + { + "0": 0, + "1": 53248, + "2": 52, + "3": 17, + "4": 1, + "5": 0, + "6": 0, + "7": 142, + "8": true, + "9": false + }, + { + "0": 0, + "1": 55296, + "2": 54, + "3": 17, + "4": 1, + "5": 0, + "6": 0, + "7": 34, + "8": true, + "9": false + }, + { + "0": 8241705229565301122, + "1": 57344, + "2": 56, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 18, + "8": true, + "9": true + } + ], + "0/53/9": 574987064, + "0/53/10": 68, + "0/53/11": 103, + "0/53/12": 223, + "0/53/13": 26, + "0/53/59": { + "0": 672, + "1": 143 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 0, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 59, 60, 61, 62, 65528, 65529, + 65531, 65532, 65533 + ], + "0/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": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQROhgkBwEkCAEwCUEExxLSpAQ5YJUVxH4v83Guzd2imtKrSMm2ADzJvNu3KGxkTF64CkFtfnORTwJmEpVfWDHJCNXRVQz0hJzXCM54nzcKNQEoARgkAgE2AwQCBAEYMAQUTE8wRXsn1uG3FSVnXrmgueY73FYwBRRT9HTfU5Nds+HA8j+/MRP+0pVyIxgwC0Dl506KGNd+m9BX72z6nm68F8SRkuJEvza7BQyg23LqfODl5ZWm8SnVH6GeN2j5TzbBIt31YApS2aNomn6YJ2YGGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyT62Yt4qMI+MorlmQ/Hxh2CpLetznVknlAbhvYAwTexpSxp9GnhR09SrcUhz3mOb0eZa2TylqcnPBhHJ2Ih2RTcKNQEpARgkAmAwBBRT9HTfU5Nds+HA8j+/MRP+0pVyIzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQI/Kc38hQyK7AkT7/pN4hiYW3LoWKT3NA43+ssMJoVpDcaZ989GXBQKIbHKbBEXzUQ1J8wfL7l2pL0Z8Lso9JwgY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BIrruNo7r0gX6j6lq1dDi5zeK3jxcTavjt2o4adCCSCYtbxOakfb7C3GXqgV4LzulFSinbewmYkdqFBHqm5pxvU=", + "2": 4939, + "3": 2, + "4": 58, + "5": "", + "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=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY" + ], + "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], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/65532": 0, + "1/6/65533": 6, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 112, + "1": 1 + } + ], + "1/29/1": [3, 6, 29, 82, 87], + "1/29/2": [], + "1/29/3": [2, 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/82/0": [ + { + "0": "Normal", + "1": 0, + "2": [ + { + "1": 0 + } + ] + }, + { + "0": "Rapid Cool", + "1": 1, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Rapid Freeze", + "1": 2, + "2": [ + { + "1": 7 + }, + { + "1": 16385 + }, + { + "1": 0 + } + ] + } + ], + "1/82/1": 0, + "1/82/65532": 1, + "1/82/65533": 2, + "1/82/65528": [1], + "1/82/65529": [0], + "1/82/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/87/0": 1, + "1/87/2": 0, + "1/87/3": 1, + "1/87/65532": 0, + "1/87/65533": 1, + "1/87/65528": [], + "1/87/65529": [], + "1/87/65531": [0, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 113, + "1": 1 + } + ], + "2/29/1": [29, 86], + "2/29/2": [], + "2/29/3": [], + "2/29/4": [ + { + "0": null, + "1": 65, + "2": 1 + } + ], + "2/29/65532": 1, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/86/0": -1800, + "2/86/1": -1800, + "2/86/2": -1500, + "2/86/3": 100, + "2/86/65532": 1, + "2/86/65533": 1, + "2/86/65528": [], + "2/86/65529": [0], + "2/86/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "3/29/0": [ + { + "0": 113, + "1": 1 + } + ], + "3/29/1": [29, 86], + "3/29/2": [], + "3/29/3": [], + "3/29/4": [ + { + "0": null, + "1": 65, + "2": 0 + } + ], + "3/29/65532": 1, + "3/29/65533": 2, + "3/29/65528": [], + "3/29/65529": [], + "3/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "3/86/0": 400, + "3/86/1": 100, + "3/86/2": 400, + "3/86/3": 100, + "3/86/65532": 1, + "3/86/65533": 1, + "3/86/65528": [], + "3/86/65529": [0], + "3/86/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/solar_power.json b/tests/components/matter/fixtures/nodes/solar_power.json new file mode 100644 index 00000000000..4b7c4af5b43 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/solar_power.json @@ -0,0 +1,334 @@ +{ + "node_id": 1, + "date_commissioned": "2025-04-26T13:59:01.038380", + "last_interview": "2025-04-26T13:59:01.038432", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 48, 49, 50, 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, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/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, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "SolarPower", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/18": "693B7500B6407671", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/2": 0, + "0/49/3": 0, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkLqrfVa", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZDZEOyJQB4D1w==", + "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": 37, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65532, 65533, 65528, 65529, 65531 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRARgkBwEkCAEwCUEEr/7Cv/8E0M1xlXrJsFennQiNL1eZk89SD0aQBqwBRM75xTNqokuHgKtObf8DW464ZlD9Pq++SURJv0WmvN2xPTcKNQEoARgkAgE2AwQCBAEYMAQUlHJKPttZOtq8Ane2vBQeAtYL97YwBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0AlmKJvIDcTdn2P6Bbc8PSdI08AqnQJRxpiogLNN1M05l0HJgGpE8G8h2W9yWuSvbeVulclJ+TLvzjafmQLWFPVGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 1, + "5": "Maison", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 1296, + "1": 1 + }, + { + "0": 17, + "1": 1 + }, + { + "0": 23, + "1": 1 + } + ], + "1/29/1": [3, 29, 47, 144, 145, 156], + "1/29/2": [], + "1/29/3": [], + "1/29/4": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "1/47/0": 0, + "1/47/1": 0, + "1/47/2": "", + "1/47/31": [], + "1/47/65532": 1, + "1/47/65533": 1, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [0, 1, 2, 31, 65532, 65533, 65528, 65529, 65531], + "1/144/0": 1, + "1/144/1": 3, + "1/144/2": [ + { + "0": 5, + "1": true, + "2": 0, + "3": 5000000, + "4": [ + { + "0": 0, + "1": 5000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": 0, + "3": 24000, + "4": [ + { + "0": 0, + "1": 24000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": 0, + "3": 300000, + "4": [ + { + "0": 0, + "1": 300000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "1/144/4": 234899, + "1/144/5": -3620, + "1/144/8": -850000, + "1/144/65532": 1, + "1/144/65533": 1, + "1/144/65528": [], + "1/144/65529": [], + "1/144/65531": [0, 1, 2, 4, 5, 8, 65532, 65533, 65528, 65529, 65531], + "1/145/0": null, + "1/145/2": { + "0": 42279000 + }, + "1/145/65532": 3, + "1/145/65533": 1, + "1/145/65528": [], + "1/145/65529": [], + "1/145/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/156/65532": 1, + "1/156/65533": 1, + "1/156/65528": [], + "1/156/65529": [], + "1/156/65531": [65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index feca62ffa31..f13d86c4557 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-BatteryChargeLevel-47-14', @@ -75,6 +76,7 @@ 'original_name': 'Battery', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-BatteryChargeLevel-47-14', @@ -123,6 +125,7 @@ 'original_name': 'Door', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-LockDoorStateSensor-257-3', @@ -171,6 +174,7 @@ 'original_name': 'Door', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ContactSensor-69-0', @@ -219,6 +223,7 @@ 'original_name': 'Water leak', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_leak', 'unique_id': '00000000000004D2-0000000000000020-MatterNodeDevice-1-WaterLeakDetector-69-0', @@ -267,6 +272,7 @@ 'original_name': 'Occupancy', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-OccupancySensor-1030-0', @@ -315,6 +321,7 @@ 'original_name': 'Occupancy', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-OccupancySensor-1030-0', @@ -363,6 +370,7 @@ 'original_name': 'Occupancy', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-OccupancySensor-1030-0', @@ -383,6 +391,104 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[pump][binary_sensor.mock_pump_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.mock_pump_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_fault', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-PumpFault-512-16', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[pump][binary_sensor.mock_pump_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Mock Pump Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_pump_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[pump][binary_sensor.mock_pump_running-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.mock_pump_running', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_running', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-PumpStatusRunning-512-16', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[pump][binary_sensor.mock_pump_running-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Mock Pump Running', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_pump_running', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -411,6 +517,7 @@ 'original_name': 'Charging status', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'evse_charging_status', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingStatusSensor-153-0', @@ -459,6 +566,7 @@ 'original_name': 'Plug', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'evse_plug_state', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvsePlugStateSensor-153-0', @@ -507,6 +615,7 @@ 'original_name': 'Supply charging state', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'evse_supply_charging_state', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseSupplyStateSensor-153-1', @@ -555,6 +664,7 @@ 'original_name': 'Boost state', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boost_state', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementBoostStateSensor-148-5', @@ -602,6 +712,7 @@ 'original_name': 'Battery alert', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_alert', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmBatteryAlertSensor-92-3', @@ -650,6 +761,7 @@ 'original_name': 'End of service', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'end_of_service', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmEndfOfServiceSensor-92-7', @@ -698,6 +810,7 @@ 'original_name': 'Hardware fault', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hardware_fault', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmHardwareFaultAlertSensor-92-6', @@ -746,6 +859,7 @@ 'original_name': 'Muted', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'muted', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmDeviceMutedSensor-92-4', @@ -793,6 +907,7 @@ 'original_name': 'Smoke', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmSmokeStateSensor-92-1', @@ -841,6 +956,7 @@ 'original_name': 'Test in progress', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'test_in_progress', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmTestInProgressSensor-92-5', diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 448136eeed2..3f18896348e 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Reset filter condition', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_filter_condition', 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-HepaFilterMonitoringResetButton-113-65529', @@ -74,6 +75,7 @@ 'original_name': 'Reset filter condition', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_filter_condition', 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-ActivatedCarbonFilterMonitoringResetButton-114-65529', @@ -121,6 +123,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', @@ -169,6 +172,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-1', @@ -217,6 +221,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', @@ -265,6 +270,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-IdentifyButton-3-1', @@ -313,6 +319,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-IdentifyButton-3-1', @@ -361,6 +368,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-IdentifyButton-3-1', @@ -409,6 +417,7 @@ 'original_name': 'Identify (1)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', @@ -457,6 +466,7 @@ 'original_name': 'Identify (2)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-IdentifyButton-3-1', @@ -505,6 +515,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', @@ -553,6 +564,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', @@ -573,6 +585,198 @@ 'state': 'unknown', }) # --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_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.mock_laundrydryer_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-OperationalStatePauseButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Pause', + }), + 'context': , + 'entity_id': 'button.mock_laundrydryer_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_resume-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.mock_laundrydryer_resume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Resume', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'resume', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-OperationalStateResumeButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_resume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Resume', + }), + 'context': , + 'entity_id': 'button.mock_laundrydryer_resume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_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.mock_laundrydryer_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-OperationalStateStartButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Start', + }), + 'context': , + 'entity_id': 'button.mock_laundrydryer_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_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.mock_laundrydryer_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-OperationalStateStopButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Stop', + }), + 'context': , + 'entity_id': 'button.mock_laundrydryer_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[microwave_oven][button.microwave_oven_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -601,6 +805,7 @@ 'original_name': 'Pause', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pause', 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-OperationalStatePauseButton-96-65529', @@ -648,6 +853,7 @@ 'original_name': 'Resume', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'resume', 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-OperationalStateResumeButton-96-65529', @@ -695,6 +901,7 @@ 'original_name': 'Start', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start', 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-OperationalStateStartButton-96-65529', @@ -742,6 +949,7 @@ 'original_name': 'Stop', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop', 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-OperationalStateStopButton-96-65529', @@ -789,6 +997,7 @@ 'original_name': 'Config', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-IdentifyButton-3-1', @@ -837,6 +1046,7 @@ 'original_name': 'Down', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-IdentifyButton-3-1', @@ -885,6 +1095,7 @@ 'original_name': 'Identify (1)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-IdentifyButton-3-1', @@ -933,6 +1144,7 @@ 'original_name': 'Identify (2)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-2-IdentifyButton-3-1', @@ -981,6 +1193,7 @@ 'original_name': 'Identify (6)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-IdentifyButton-3-1', @@ -1029,6 +1242,7 @@ 'original_name': 'Up', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-IdentifyButton-3-1', @@ -1077,6 +1291,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', @@ -1125,6 +1340,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', @@ -1173,6 +1389,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-IdentifyButton-3-1', @@ -1221,6 +1438,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-IdentifyButton-3-1', @@ -1269,6 +1487,7 @@ 'original_name': 'Pause', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pause', 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-OperationalStatePauseButton-96-65529', @@ -1316,6 +1535,7 @@ 'original_name': 'Start', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start', 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-OperationalStateStartButton-96-65529', @@ -1363,6 +1583,7 @@ 'original_name': 'Stop', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop', 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-OperationalStateStopButton-96-65529', @@ -1410,6 +1631,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', @@ -1458,6 +1680,7 @@ 'original_name': 'Pause', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pause', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStatePauseButton-96-65529', @@ -1505,6 +1728,7 @@ 'original_name': 'Resume', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'resume', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateResumeButton-96-65529', @@ -1552,6 +1776,7 @@ 'original_name': 'Start', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateStartButton-96-65529', @@ -1599,6 +1824,7 @@ 'original_name': 'Stop', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateStopButton-96-65529', @@ -1618,6 +1844,55 @@ 'state': 'unknown', }) # --- +# name: test_buttons[silabs_refrigerator][button.refrigerator_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.refrigerator_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_refrigerator][button.refrigerator_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Refrigerator Identify', + }), + 'context': , + 'entity_id': 'button.refrigerator_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[smoke_detector][button.smoke_sensor_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1646,6 +1921,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', @@ -1694,6 +1970,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', @@ -1742,6 +2019,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-0-IdentifyButton-3-1', @@ -1790,6 +2068,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', @@ -1838,6 +2117,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', @@ -1886,6 +2166,7 @@ 'original_name': 'Identify', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 8aeb1aaafdd..07a5a69d801 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -34,6 +34,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-5-MatterThermostat-513-0', @@ -97,6 +98,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-MatterThermostat-513-0', @@ -164,6 +166,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterThermostat-513-0', @@ -233,6 +236,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterThermostat-513-0', diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr index c83dcf63c6b..c8e2c03739a 100644 --- a/tests/components/matter/snapshots/test_cover.ambr +++ b/tests/components/matter/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-MatterCoverPositionAwareLiftAndTilt-258-10', @@ -78,6 +79,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-MatterCover-258-10', @@ -127,6 +129,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterCoverPositionAwareLift-258-10', @@ -177,6 +180,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-MatterCoverPositionAwareTilt-258-10', @@ -227,6 +231,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-MatterCover-258-10', diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr index 153f5751f14..aa4fb483248 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -34,6 +34,7 @@ 'original_name': 'Button', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-GenericSwitch-59-1', @@ -96,6 +97,7 @@ 'original_name': 'Button (1)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-GenericSwitch-59-1', @@ -160,6 +162,7 @@ 'original_name': 'Fancy Button', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-2-GenericSwitch-59-1', @@ -227,6 +230,7 @@ 'original_name': 'Config', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-GenericSwitch-59-1', @@ -295,6 +299,7 @@ 'original_name': 'Down', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-GenericSwitch-59-1', @@ -363,6 +368,7 @@ 'original_name': 'Up', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-GenericSwitch-59-1', diff --git a/tests/components/matter/snapshots/test_fan.ambr b/tests/components/matter/snapshots/test_fan.ambr index e4dc14967e5..e7ae2647d5b 100644 --- a/tests/components/matter/snapshots/test_fan.ambr +++ b/tests/components/matter/snapshots/test_fan.ambr @@ -36,6 +36,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-MatterFan-514-0', @@ -106,6 +107,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterFan-514-0', @@ -173,6 +175,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterFan-514-0', @@ -238,6 +241,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterFan-514-0', diff --git a/tests/components/matter/snapshots/test_light.ambr b/tests/components/matter/snapshots/test_light.ambr index a56f8f891e9..83b953c9b04 100644 --- a/tests/components/matter/snapshots/test_light.ambr +++ b/tests/components/matter/snapshots/test_light.ambr @@ -35,6 +35,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', @@ -111,6 +112,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', @@ -168,6 +170,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterLight-6-0', @@ -231,6 +234,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', @@ -309,6 +313,7 @@ 'original_name': 'Light (1)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'light', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-MatterLight-6-0', @@ -372,6 +377,7 @@ 'original_name': 'Light (6)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'light', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-MatterLight-6-0', @@ -440,6 +446,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', @@ -502,6 +509,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', @@ -576,6 +584,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', @@ -644,6 +653,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-MatterLight-6-0', diff --git a/tests/components/matter/snapshots/test_lock.ambr b/tests/components/matter/snapshots/test_lock.ambr index 10ba84dd49b..7384449839c 100644 --- a/tests/components/matter/snapshots/test_lock.ambr +++ b/tests/components/matter/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLock-257-0', @@ -75,6 +76,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLock-257-0', diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index e1ee782cd3b..5ba0f275f8d 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'On level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_level-8-17', @@ -88,6 +89,7 @@ 'original_name': 'Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'off_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-off_transition_time-8-19', @@ -145,6 +147,7 @@ 'original_name': 'On level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_level-8-17', @@ -201,6 +204,7 @@ 'original_name': 'On/Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_off_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_off_transition_time-8-16', @@ -258,6 +262,7 @@ 'original_name': 'On transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_transition_time-8-18', @@ -315,6 +320,7 @@ 'original_name': 'On level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-on_level-8-17', @@ -371,6 +377,7 @@ 'original_name': 'On/Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_off_transition_time', 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-on_off_transition_time-8-16', @@ -395,6 +402,122 @@ 'state': '1.0', }) # --- +# name: test_numbers[door_lock][number.mock_door_lock_automatic_relock_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65534, + '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.mock_door_lock_automatic_relock_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic relock timer', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_relock_timer', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-AutoRelockTimer-257-35', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[door_lock][number.mock_door_lock_automatic_relock_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Automatic relock timer', + 'max': 65534, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_automatic_relock_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65534, + '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.mock_door_lock_automatic_relock_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic relock timer', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_relock_timer', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-AutoRelockTimer-257-35', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_automatic_relock_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Automatic relock timer', + 'max': 65534, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- # name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -428,6 +551,7 @@ 'original_name': 'Temperature offset', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_offset', 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTemperatureOffset-513-16', @@ -486,6 +610,7 @@ 'original_name': 'Altitude above sea level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'altitude', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-EveWeatherAltitude-319486977-319422483', @@ -544,6 +669,7 @@ 'original_name': 'On level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_level-8-17', @@ -567,6 +693,63 @@ 'state': '255', }) # --- +# name: test_numbers[mounted_dimmable_load_control_fixture][number.mock_mounted_dimmable_load_control_on_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 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_mounted_dimmable_load_control_on_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': 'On level', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'on_level', + 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-on_level-8-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[mounted_dimmable_load_control_fixture][number.mock_mounted_dimmable_load_control_on_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Mounted dimmable load control On level', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_mounted_dimmable_load_control_on_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_numbers[multi_endpoint_light][number.inovelli_off_transition_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -600,6 +783,7 @@ 'original_name': 'Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'off_transition_time', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-off_transition_time-8-19', @@ -657,6 +841,7 @@ 'original_name': 'On level (1)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-on_level-8-17', @@ -713,6 +898,7 @@ 'original_name': 'On level (6)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-on_level-8-17', @@ -769,6 +955,7 @@ 'original_name': 'On/Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_off_transition_time', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-on_off_transition_time-8-16', @@ -826,6 +1013,7 @@ 'original_name': 'On transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_transition_time', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-on_transition_time-8-18', @@ -883,6 +1071,7 @@ 'original_name': 'Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'off_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-off_transition_time-8-19', @@ -940,6 +1129,7 @@ 'original_name': 'On level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_level-8-17', @@ -996,6 +1186,7 @@ 'original_name': 'On/Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_off_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_off_transition_time-8-16', @@ -1053,6 +1244,7 @@ 'original_name': 'On transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_transition_time-8-18', @@ -1110,6 +1302,7 @@ 'original_name': 'Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'off_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-off_transition_time-8-19', @@ -1167,6 +1360,7 @@ 'original_name': 'On level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_level-8-17', @@ -1223,6 +1417,7 @@ 'original_name': 'On/Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_off_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_off_transition_time-8-16', @@ -1280,6 +1475,7 @@ 'original_name': 'On transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_transition_time-8-18', @@ -1337,6 +1533,7 @@ 'original_name': 'Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'off_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-off_transition_time-8-19', @@ -1394,6 +1591,7 @@ 'original_name': 'On level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_level-8-17', @@ -1450,6 +1648,7 @@ 'original_name': 'On/Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_off_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_off_transition_time-8-16', @@ -1507,6 +1706,7 @@ 'original_name': 'On transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_transition_time', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-on_transition_time-8-18', @@ -1564,6 +1764,7 @@ 'original_name': 'On level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_level', 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-on_level-8-17', @@ -1620,6 +1821,7 @@ 'original_name': 'On/Off transition time', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_off_transition_time', 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-on_off_transition_time-8-16', @@ -1644,3 +1846,60 @@ 'state': '0.0', }) # --- +# name: test_numbers[pump][number.mock_pump_on_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 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_pump_on_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': 'On level', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'on_level', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-on_level-8-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[pump][number.mock_pump_on_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Pump On level', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_pump_on_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 5222dda1ab5..092928ff1d4 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Lighting', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterModeSelect-80-3', @@ -92,6 +93,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -117,6 +119,65 @@ 'state': 'previous', }) # --- +# name: test_selects[cooktop][select.mock_cooktop_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_cooktop_temperature_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': 'Temperature level', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_level', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-2-TemperatureControlSelectedTemperatureLevel-86-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[cooktop][select.mock_cooktop_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Cooktop Temperature level', + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.mock_cooktop_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Medium', + }) +# --- # name: test_selects[dimmable_light][select.mock_dimmable_light_led_color-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -161,6 +222,7 @@ 'original_name': 'LED Color', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-6-MatterModeSelect-80-3', @@ -230,6 +292,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -290,6 +353,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -350,6 +414,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -375,6 +440,67 @@ 'state': 'off', }) # --- +# name: test_selects[door_lock][select.mock_door_lock_sound_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'silent', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mock_door_lock_sound_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound volume', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_lock_sound_volume', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockSoundVolume-257-36', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[door_lock][select.mock_door_lock_sound_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Sound volume', + 'options': list([ + 'silent', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.mock_door_lock_sound_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'silent', + }) +# --- # name: test_selects[door_lock_with_unbolt][select.mock_door_lock_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -410,6 +536,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -435,6 +562,67 @@ 'state': 'off', }) # --- +# name: test_selects[door_lock_with_unbolt][select.mock_door_lock_sound_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'silent', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mock_door_lock_sound_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound volume', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_lock_sound_volume', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockSoundVolume-257-36', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[door_lock_with_unbolt][select.mock_door_lock_sound_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Sound volume', + 'options': list([ + 'silent', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.mock_door_lock_sound_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'silent', + }) +# --- # name: test_selects[eve_energy_plug][select.eve_energy_plug_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -470,6 +658,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -530,6 +719,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -588,6 +778,7 @@ 'original_name': 'Temperature display mode', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_mode', 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', @@ -645,6 +836,7 @@ 'original_name': 'Lighting', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterModeSelect-80-3', @@ -704,6 +896,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -729,6 +922,126 @@ 'state': 'previous', }) # --- +# name: test_selects[laundry_dryer][select.mock_laundrydryer_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_laundrydryer_temperature_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': 'Temperature level', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_level', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-TemperatureControlSelectedTemperatureLevel-86-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[laundry_dryer][select.mock_laundrydryer_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Temperature level', + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.mock_laundrydryer_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Low', + }) +# --- +# name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup', + 'has_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-on behavior on startup', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'startup_on_off', + 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Mounted dimmable load control Power-on behavior on startup', + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'context': , + 'entity_id': 'select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_selects[multi_endpoint_light][select.inovelli_dimming_edge-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -762,6 +1075,7 @@ 'original_name': 'Dimming Edge', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-MatterModeSelect-80-3', @@ -831,6 +1145,7 @@ 'original_name': 'Dimming Speed', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-MatterModeSelect-80-3', @@ -911,6 +1226,7 @@ 'original_name': 'LED Color', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-MatterModeSelect-80-3', @@ -980,6 +1296,7 @@ 'original_name': 'Power-on behavior on startup (1)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -1040,6 +1357,7 @@ 'original_name': 'Power-on behavior on startup (6)', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-MatterStartUpOnOff-6-16387', @@ -1098,6 +1416,7 @@ 'original_name': 'Relay', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-MatterModeSelect-80-3', @@ -1154,6 +1473,7 @@ 'original_name': 'Smart Bulb Mode', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-2-MatterModeSelect-80-3', @@ -1215,6 +1535,7 @@ 'original_name': 'Switch Mode', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-MatterModeSelect-80-3', @@ -1278,6 +1599,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -1338,6 +1660,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -1398,6 +1721,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -1458,6 +1782,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -1518,6 +1843,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -1543,6 +1869,197 @@ 'state': 'previous', }) # --- +# name: test_selects[oven][select.mock_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Bake', + 'Convection', + 'Grill', + 'Roast', + 'Clean', + 'Convection Bake', + 'Convection Roast', + 'Warming', + 'Proofing', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_oven_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-MatterOvenMode-73-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[oven][select.mock_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Oven Mode', + 'options': list([ + 'Bake', + 'Convection', + 'Grill', + 'Roast', + 'Clean', + 'Convection Bake', + 'Convection Roast', + 'Warming', + 'Proofing', + ]), + }), + 'context': , + 'entity_id': 'select.mock_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Bake', + }) +# --- +# name: test_selects[oven][select.mock_oven_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_oven_temperature_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': 'Temperature level', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_level', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-4-TemperatureControlSelectedTemperatureLevel-86-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[oven][select.mock_oven_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Oven Temperature level', + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.mock_oven_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Low', + }) +# --- +# name: test_selects[pump][select.mock_pump_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'normal', + 'minimum', + 'maximum', + 'local', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_pump_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_operation_mode', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-PumpConfigurationAndControlOperationMode-512-32', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[pump][select.mock_pump_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Pump mode', + 'options': list([ + 'normal', + 'minimum', + 'maximum', + 'local', + ]), + }), + 'context': , + 'entity_id': 'select.mock_pump_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- # name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1579,6 +2096,7 @@ 'original_name': 'Energy management mode', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_energy_management_mode', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterDeviceEnergyManagementMode-159-1', @@ -1640,6 +2158,7 @@ 'original_name': 'Mode', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterEnergyEvseMode-157-1', @@ -1698,6 +2217,7 @@ 'original_name': 'Number of rinses', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'laundry_washer_number_of_rinses', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherNumberOfRinses-83-2', @@ -1756,6 +2276,7 @@ 'original_name': 'Spin speed', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'laundry_washer_spin_speed', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-LaundryWasherControlsSpinSpeed-83-1', @@ -1815,6 +2336,7 @@ 'original_name': 'Temperature level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_level', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-TemperatureControlSelectedTemperatureLevel-86-4', @@ -1839,6 +2361,65 @@ 'state': 'Colors', }) # --- +# name: test_selects[silabs_refrigerator][select.refrigerator_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Normal', + 'Rapid Cool', + 'Rapid Freeze', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.refrigerator_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-1-MatterRefrigeratorAndTemperatureControlledCabinetMode-82-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_refrigerator][select.refrigerator_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Mode', + 'options': list([ + 'Normal', + 'Rapid Cool', + 'Rapid Freeze', + ]), + }), + 'context': , + 'entity_id': 'select.refrigerator_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Normal', + }) +# --- # name: test_selects[silabs_water_heater][select.water_heater_energy_management_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1875,6 +2456,7 @@ 'original_name': 'Energy management mode', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_energy_management_mode', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-MatterDeviceEnergyManagementMode-159-1', @@ -1936,6 +2518,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', @@ -1994,6 +2577,7 @@ 'original_name': 'Temperature display mode', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_mode', 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', @@ -2053,6 +2637,7 @@ 'original_name': 'Clean mode', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'clean_mode', 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterRvcCleanMode-85-1', @@ -2114,6 +2699,7 @@ 'original_name': 'Power-on behavior on startup', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'startup_on_off', 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 2c6ef8ad51b..3a5a937b4a4 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Activated carbon filter condition', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activated_carbon_filter_condition', 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-ActivatedCarbonFilterCondition-114-0', @@ -87,6 +88,7 @@ 'original_name': 'Air quality', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-AirQuality-91-0', @@ -145,6 +147,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-CarbonDioxideSensor-1037-0', @@ -197,6 +200,7 @@ 'original_name': 'Carbon monoxide', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-CarbonMonoxideSensor-1036-0', @@ -249,6 +253,7 @@ 'original_name': 'Hepa filter condition', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hepa_filter_condition', 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-HepaFilterCondition-113-0', @@ -300,6 +305,7 @@ 'original_name': 'Humidity', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-4-HumiditySensor-1029-0', @@ -352,6 +358,7 @@ 'original_name': 'Nitrogen dioxide', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-NitrogenDioxideSensor-1043-0', @@ -404,6 +411,7 @@ 'original_name': 'Ozone', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-OzoneConcentrationSensor-1045-0', @@ -456,6 +464,7 @@ 'original_name': 'PM1', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-PM1Sensor-1068-0', @@ -508,6 +517,7 @@ 'original_name': 'PM10', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-PM10Sensor-1069-0', @@ -560,6 +570,7 @@ 'original_name': 'PM2.5', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-PM25Sensor-1066-0', @@ -606,12 +617,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-3-TemperatureSensor-1026-0', @@ -658,12 +673,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-5-ThermostatLocalTemperature-513-0', @@ -716,6 +735,7 @@ 'original_name': 'Volatile organic compounds parts', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-TotalVolatileOrganicCompoundsSensor-1070-0', @@ -775,6 +795,7 @@ 'original_name': 'Air quality', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-AirQuality-91-0', @@ -833,6 +854,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-CarbonDioxideSensor-1037-0', @@ -885,6 +907,7 @@ 'original_name': 'Humidity', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-HumiditySensor-1029-0', @@ -937,6 +960,7 @@ 'original_name': 'Nitrogen dioxide', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-NitrogenDioxideSensor-1043-0', @@ -989,6 +1013,7 @@ 'original_name': 'PM1', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PM1Sensor-1068-0', @@ -1041,6 +1066,7 @@ 'original_name': 'PM10', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PM10Sensor-1069-0', @@ -1093,6 +1119,7 @@ 'original_name': 'PM2.5', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PM25Sensor-1066-0', @@ -1139,12 +1166,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-TemperatureSensor-1026-0', @@ -1197,6 +1228,7 @@ 'original_name': 'Volatile organic compounds parts', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-TotalVolatileOrganicCompoundsSensor-1070-0', @@ -1219,6 +1251,62 @@ 'state': '189.0', }) # --- +# name: test_sensors[cooktop][sensor.mock_cooktop_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_cooktop_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-2-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[cooktop][sensor.mock_cooktop_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Cooktop Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_cooktop_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '180.0', + }) +# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1249,6 +1337,7 @@ 'original_name': 'Battery', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSource-47-12', @@ -1295,6 +1384,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1304,6 +1396,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', @@ -1359,6 +1452,7 @@ 'original_name': 'Current', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-EveEnergySensorWattCurrent-319486977-319422473', @@ -1414,6 +1508,7 @@ 'original_name': 'Energy', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-EveEnergySensorWattAccumulated-319486977-319422475', @@ -1469,6 +1564,7 @@ 'original_name': 'Power', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-EveEnergySensorWatt-319486977-319422474', @@ -1524,6 +1620,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-EveEnergySensorVoltage-319486977-319422472', @@ -1582,6 +1679,7 @@ 'original_name': 'Current', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', @@ -1640,6 +1738,7 @@ 'original_name': 'Energy', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalEnergyMeasurementCumulativeEnergyImported-145-1', @@ -1698,6 +1797,7 @@ 'original_name': 'Power', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', @@ -1756,6 +1856,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', @@ -1808,6 +1909,7 @@ 'original_name': 'Battery', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-PowerSource-47-12', @@ -1854,12 +1956,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', @@ -1910,6 +2016,7 @@ 'original_name': 'Valve position', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve_position', 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveThermoValvePosition-319486977-319422488', @@ -1954,6 +2061,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1963,6 +2073,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', @@ -1982,7 +2093,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.050', + 'state': '3.05', }) # --- # name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery-entry] @@ -2015,6 +2126,7 @@ 'original_name': 'Battery', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-0-PowerSource-47-12', @@ -2067,6 +2179,7 @@ 'original_name': 'Humidity', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-HumiditySensor-1029-0', @@ -2122,6 +2235,7 @@ 'original_name': 'Pressure', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-EveWeatherPressure-319486977-319422484', @@ -2168,12 +2282,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-TemperatureSensor-1026-0', @@ -2220,6 +2338,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -2229,6 +2350,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', @@ -2281,6 +2403,7 @@ 'original_name': 'Flow', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flow', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-FlowSensor-1028-0', @@ -2302,6 +2425,159 @@ 'state': '0.0', }) # --- +# name: test_sensors[generic_switch][sensor.mock_generic_switch_current_switch_position-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.mock_generic_switch_current_switch_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[generic_switch][sensor.mock_generic_switch_current_switch_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Generic Switch Current switch position', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.mock_generic_switch_current_switch_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[generic_switch_multi][sensor.mock_generic_switch_current_switch_position_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_generic_switch_current_switch_position_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (1)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[generic_switch_multi][sensor.mock_generic_switch_current_switch_position_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Generic Switch Current switch position (1)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.mock_generic_switch_current_switch_position_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[generic_switch_multi][sensor.mock_generic_switch_fancy_button-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.mock_generic_switch_fancy_button', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fancy Button', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-2-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[generic_switch_multi][sensor.mock_generic_switch_fancy_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Generic Switch Fancy Button', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.mock_generic_switch_fancy_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[humidity_sensor][sensor.mock_humidity_sensor_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2332,6 +2608,7 @@ 'original_name': 'Humidity', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-HumiditySensor-1029-0', @@ -2354,6 +2631,128 @@ 'state': '0.0', }) # --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_current_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_laundrydryer_current_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': 'Current phase', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-OperationalStateCurrentPhase-96-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_current_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Laundrydryer Current phase', + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_laundrydryer_current_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pre-soak', + }) +# --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_laundrydryer_operational_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': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-OperationalState-96-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Laundrydryer Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_laundrydryer_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- # name: test_sensors[light_sensor][sensor.mock_light_sensor_illuminance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2384,6 +2783,7 @@ 'original_name': 'Illuminance', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-LightSensor-1024-0', @@ -2441,6 +2841,7 @@ 'original_name': 'Operational state', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operational_state', 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-OperationalState-96-4', @@ -2467,6 +2868,391 @@ 'state': 'stopped', }) # --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_config-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.inovelli_config', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Config', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_config-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli Config', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.inovelli_config', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_down-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.inovelli_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Down', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli Down', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.inovelli_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_up-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.inovelli_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Up', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli Up', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.inovelli_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_current_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pre-heating', + 'pre-heated', + 'cooling down', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_oven_current_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': 'Current phase', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-OvenCavityOperationalStateCurrentPhase-72-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_current_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Oven Current phase', + 'options': list([ + 'pre-heating', + 'pre-heated', + 'cooling down', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_oven_current_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pre-heating', + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_oven_operational_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': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-OvenCavityOperationalState-72-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Oven Operational state', + 'options': list([ + 'stopped', + 'running', + 'error', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_oven_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_temperature_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': None, + 'entity_id': 'sensor.mock_oven_temperature_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Oven Temperature (2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_oven_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.55', + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_temperature_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': None, + 'entity_id': 'sensor.mock_oven_temperature_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature (4)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-4-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_temperature_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Oven Temperature (4)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_oven_temperature_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[pressure_sensor][sensor.mock_pressure_sensor_pressure-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2491,12 +3277,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pressure', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PressureSensor-1027-0', @@ -2519,6 +3309,288 @@ 'state': '0.0', }) # --- +# name: test_sensors[pump][sensor.mock_pump_control_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'constant_speed', + 'constant_pressure', + 'proportional_pressure', + 'constant_flow', + 'constant_temperature', + 'automatic', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_pump_control_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Control mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_control_mode', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-PumpControlMode-512-33', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_control_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Pump Control mode', + 'options': list([ + 'constant_speed', + 'constant_pressure', + 'proportional_pressure', + 'constant_flow', + 'constant_temperature', + 'automatic', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_pump_control_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'constant_temperature', + }) +# --- +# name: test_sensors[pump][sensor.mock_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.mock_pump_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flow', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flow', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-FlowSensor-1028-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Pump Flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_pump_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_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.mock_pump_pressure', + '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': 'Pressure', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-PressureSensor-1027-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Mock Pump Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_pump_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_rotation_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': None, + 'entity_id': 'sensor.mock_pump_rotation_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': 'Rotation speed', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_speed', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-PumpSpeed-512-20', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_rotation_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Pump Rotation speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.mock_pump_rotation_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_pump_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Pump Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_pump_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- # name: test_sensors[room_airconditioner][sensor.room_airconditioner_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2543,12 +3615,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-2-TemperatureSensor-1026-0', @@ -2607,6 +3683,7 @@ 'original_name': 'Current', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', @@ -2665,6 +3742,7 @@ 'original_name': 'Energy', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalEnergyMeasurementCumulativeEnergyImported-145-1', @@ -2723,6 +3801,7 @@ 'original_name': 'Operational state', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operational_state', 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-OperationalState-96-4', @@ -2786,6 +3865,7 @@ 'original_name': 'Power', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', @@ -2844,6 +3924,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', @@ -2866,6 +3947,70 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_appliance_energy_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'offline', + 'online', + 'fault', + 'power_adjust_active', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_appliance_energy_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': 'Appliance energy state', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-ESAState-152-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_appliance_energy_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'evse Appliance energy state', + 'options': list([ + 'offline', + 'online', + 'fault', + 'power_adjust_active', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_appliance_energy_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'online', + }) +# --- # name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2902,6 +4047,7 @@ 'original_name': 'Circuit capacity', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'evse_circuit_capacity', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseCircuitCapacity-153-5', @@ -2971,6 +4117,7 @@ 'original_name': 'Fault state', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'evse_fault_state', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseFaultState-153-2', @@ -3045,6 +4192,7 @@ 'original_name': 'Max charge current', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'evse_max_charge_current', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMaximumChargeCurrent-153-7', @@ -3103,6 +4251,7 @@ 'original_name': 'Min charge current', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'evse_min_charge_current', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMinimumChargeCurrent-153-6', @@ -3161,6 +4310,7 @@ 'original_name': 'User max charge current', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'evse_user_max_charge_current', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseUserMaximumChargeCurrent-153-9', @@ -3219,6 +4369,7 @@ 'original_name': 'Current', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', @@ -3275,6 +4426,7 @@ 'original_name': 'Current phase', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_phase', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateCurrentPhase-96-1', @@ -3336,6 +4488,7 @@ 'original_name': 'Energy', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalEnergyMeasurementCumulativeEnergyImported-145-1', @@ -3393,6 +4546,7 @@ 'original_name': 'Operational state', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operational_state', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalState-96-4', @@ -3455,6 +4609,7 @@ 'original_name': 'Power', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', @@ -3513,6 +4668,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', @@ -3535,6 +4691,70 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_appliance_energy_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'offline', + 'online', + 'fault', + 'power_adjust_active', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_appliance_energy_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': 'Appliance energy state', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_state', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ESAState-152-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_appliance_energy_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Water Heater Appliance energy state', + 'options': list([ + 'offline', + 'online', + 'fault', + 'power_adjust_active', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.water_heater_appliance_energy_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'online', + }) +# --- # name: test_sensors[silabs_water_heater][sensor.water_heater_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3571,6 +4791,7 @@ 'original_name': 'Current', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', @@ -3623,6 +4844,7 @@ 'original_name': 'Hot water level', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tank_percentage', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementTankPercentage-148-4', @@ -3680,6 +4902,7 @@ 'original_name': 'Power', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', @@ -3738,6 +4961,7 @@ 'original_name': 'Required heating energy', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'estimated_heat_required', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementEstimatedHeatRequired-148-3', @@ -3784,12 +5008,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Tank volume', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tank_volume', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementTankVolume-148-2', @@ -3848,6 +5076,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', @@ -3900,6 +5129,7 @@ 'original_name': 'Battery', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSource-47-12', @@ -3950,6 +5180,7 @@ 'original_name': 'Battery type', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_replacement_description', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', @@ -3993,6 +5224,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -4002,6 +5236,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', @@ -4021,7 +5256,243 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.000', + 'state': '0.0', + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_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.solarpower_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SolarPower Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarpower_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-3.62', + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_energy_exported-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.solarpower_energy_exported', + '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': 'Energy exported', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalEnergyMeasurementCumulativeEnergyExported-145-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_energy_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarPower Energy exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarpower_energy_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.279', + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_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.solarpower_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarPower Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarpower_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-850.0', + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_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.solarpower_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SolarPower Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarpower_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '234.899', }) # --- # name: test_sensors[temperature_sensor][sensor.mock_temperature_sensor_temperature-entry] @@ -4048,12 +5519,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-TemperatureSensor-1026-0', @@ -4100,12 +5575,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', @@ -4166,6 +5645,7 @@ 'original_name': 'Operational state', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operational_state', 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-RvcOperationalState-97-4', @@ -4195,6 +5675,104 @@ 'state': 'unknown', }) # --- +# name: test_sensors[window_covering_full][sensor.mock_full_window_covering_target_opening_position-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_full_window_covering_target_opening_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target opening position', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_covering_target_position', + 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-TargetPositionLiftPercent100ths-258-11', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[window_covering_full][sensor.mock_full_window_covering_target_opening_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Full Window Covering Target opening position', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_full_window_covering_target_opening_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[window_covering_pa_lift][sensor.longan_link_wncv_da01_target_opening_position-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.longan_link_wncv_da01_target_opening_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target opening position', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_covering_target_position', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-TargetPositionLiftPercent100ths-258-11', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[window_covering_pa_lift][sensor.longan_link_wncv_da01_target_opening_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Longan link WNCV DA01 Target opening position', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.longan_link_wncv_da01_target_opening_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4228,6 +5806,7 @@ 'original_name': 'Current', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsCurrent-2820-1288', @@ -4283,6 +5862,7 @@ 'original_name': 'Power', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementActivePower-2820-1291', @@ -4338,6 +5918,7 @@ 'original_name': 'Voltage', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsVoltage-2820-1285', diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index d60a2933e6f..01881448e13 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -1,4 +1,102 @@ # serializer version: 1 +# name: test_switches[cooktop][switch.mock_cooktop_power_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': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_cooktop_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': 'Power (1)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[cooktop][switch.mock_cooktop_power_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Cooktop Power (1)', + }), + 'context': , + 'entity_id': 'switch.mock_cooktop_power_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[cooktop][switch.mock_cooktop_power_2-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_cooktop_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': 'Power (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-2-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[cooktop][switch.mock_cooktop_power_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Cooktop Power (2)', + }), + 'context': , + 'entity_id': 'switch.mock_cooktop_power_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[door_lock][switch.mock_door_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -27,6 +125,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterSwitch-6-0', @@ -75,6 +174,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterSwitch-6-0', @@ -123,6 +223,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-MatterPlug-6-0', @@ -171,6 +272,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-MatterPlug-6-0', @@ -219,6 +321,7 @@ 'original_name': 'Child lock', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTrvChildLock-516-1', @@ -238,6 +341,104 @@ 'state': 'off', }) # --- +# name: test_switches[laundry_dryer][switch.mock_laundrydryer_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.mock_laundrydryer_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[laundry_dryer][switch.mock_laundrydryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Laundrydryer Power', + }), + 'context': , + 'entity_id': 'switch.mock_laundrydryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[mounted_dimmable_load_control_fixture][switch.mock_mounted_dimmable_load_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': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_mounted_dimmable_load_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-MatterSwitch-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[mounted_dimmable_load_control_fixture][switch.mock_mounted_dimmable_load_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Mock Mounted dimmable load control', + }), + 'context': , + 'entity_id': 'switch.mock_mounted_dimmable_load_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -266,6 +467,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterPlug-6-0', @@ -286,6 +488,153 @@ 'state': 'off', }) # --- +# name: test_switches[oven][switch.mock_oven_power_3-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_oven_power_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-3-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[oven][switch.mock_oven_power_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Oven Power (3)', + }), + 'context': , + 'entity_id': 'switch.mock_oven_power_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[oven][switch.mock_oven_power_4-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_oven_power_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power (4)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-4-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[oven][switch.mock_oven_power_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Oven Power (4)', + }), + 'context': , + 'entity_id': 'switch.mock_oven_power_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[pump][switch.mock_pump_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.mock_pump_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[pump][switch.mock_pump_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Pump Power', + }), + 'context': , + 'entity_id': 'switch.mock_pump_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[room_airconditioner][switch.room_airconditioner_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -314,6 +663,7 @@ 'original_name': 'Power', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterPowerToggle-6-0', @@ -362,6 +712,7 @@ 'original_name': 'Enable charging', 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'evse_charging_switch', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingSwitch-153-1', @@ -381,6 +732,55 @@ 'state': 'on', }) # --- +# name: test_switches[silabs_refrigerator][switch.refrigerator_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.refrigerator_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-1-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[silabs_refrigerator][switch.refrigerator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Refrigerator Power', + }), + 'context': , + 'entity_id': 'switch.refrigerator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[switch_unit][switch.mock_switchunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -409,6 +809,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterSwitch-6-0', @@ -457,6 +858,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterSwitch-6-0', @@ -505,6 +907,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterPlug-6-0', diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index 0703a1af4c7..cb859147d75 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1', diff --git a/tests/components/matter/snapshots/test_valve.ambr b/tests/components/matter/snapshots/test_valve.ambr index 99da4c2d0f6..6c178449083 100644 --- a/tests/components/matter/snapshots/test_valve.ambr +++ b/tests/components/matter/snapshots/test_valve.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-MatterValve-129-4', diff --git a/tests/components/matter/snapshots/test_water_heater.ambr b/tests/components/matter/snapshots/test_water_heater.ambr index fcf9a7665fd..6dd483fb1d7 100644 --- a/tests/components/matter/snapshots/test_water_heater.ambr +++ b/tests/components/matter/snapshots/test_water_heater.ambr @@ -35,6 +35,7 @@ 'original_name': None, 'platform': 'matter', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-MatterWaterHeater-513-18', diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index c20c5cb7f29..e221140b85b 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode from matter_server.common.models import EventType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.matter.binary_sensor import ( DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS, @@ -217,3 +217,43 @@ async def test_water_heater( state = hass.states.get("binary_sensor.water_heater_boost_state") assert state assert state.state == "on" + + +@pytest.mark.parametrize("node_fixture", ["pump"]) +async def test_pump( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test pump sensors.""" + # PumpStatus + state = hass.states.get("binary_sensor.mock_pump_running") + assert state + assert state.state == "on" + + set_node_attribute(matter_node, 1, 512, 16, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.mock_pump_running") + assert state + assert state.state == "off" + + # PumpStatus --> DeviceFault bit + state = hass.states.get("binary_sensor.mock_pump_problem") + assert state + assert state.state == "unknown" + + set_node_attribute(matter_node, 1, 512, 16, 1) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.mock_pump_problem") + assert state + assert state.state == "on" + + # PumpStatus --> SupplyFault bit + set_node_attribute(matter_node, 1, 512, 16, 2) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.mock_pump_problem") + assert state + assert state.state == "on" diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py index cbf62dd80c7..2af2d40cb74 100644 --- a/tests/components/matter/test_button.py +++ b/tests/components/matter/test_button.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 037ec4e7626..7761d5d27da 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -6,7 +6,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ClimateEntityFeature, HVACAction, HVACMode from homeassistant.const import Platform diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py index 224aabd9082..cdf7f6300be 100644 --- a/tests/components/matter/test_cover.py +++ b/tests/components/matter/test_cover.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import CoverEntityFeature, CoverState from homeassistant.const import Platform diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index 651c71a5dce..8098d4dd639 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode from matter_server.common.models import EventType, MatterNodeEvent import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES from homeassistant.const import Platform diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py index 6ed95b0ecc2..6c3acd1978d 100644 --- a/tests/components/matter/test_fan.py +++ b/tests/components/matter/test_fan.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, call from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_DIRECTION, diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index c49b47c9106..b600ededa6e 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ColorMode from homeassistant.const import Platform diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index bb03b296fc6..ab3995e6771 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ATTR_CODE, STATE_UNKNOWN, Platform diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 2a4eea1c324..c94b92dbc46 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -7,7 +7,7 @@ from matter_server.common import custom_clusters from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 2403b4b1623..7045b60a24e 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -6,7 +6,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -99,6 +99,24 @@ async def test_attribute_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state.state == "on" + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": entity_id, + "option": "off", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.OnOff.Attributes.StartUpOnOff, + ), + value=0, + ) # test that an invalid value (e.g. 253) leads to an unknown state set_node_attribute(matter_node, 1, 6, 16387, 253) await trigger_subscription_callback(hass, matter_client) @@ -198,3 +216,22 @@ async def test_map_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("select.laundrywasher_number_of_rinses") assert state.state == "normal" + + +@pytest.mark.parametrize("node_fixture", ["pump"]) +async def test_pump( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test MatterAttributeSelectEntity entities are discovered and working from a pump fixture.""" + # OperationMode + state = hass.states.get("select.mock_pump_mode") + assert state + assert state.state == "normal" + assert state.attributes["options"] == ["normal", "minimum", "maximum", "local"] + + set_node_attribute(matter_node, 1, 512, 32, 3) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.mock_pump_mode") + assert state.state == "local" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 03ffa31125e..e15e3f9f53e 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant @@ -17,7 +17,7 @@ from .common import ( ) -@pytest.mark.usefixtures("matter_devices") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "matter_devices") async def test_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -511,3 +511,47 @@ async def test_water_heater( state = hass.states.get("sensor.water_heater_hot_water_level") assert state assert state.state == "50" + + # DeviceEnergyManagement -> ESAState attribute + state = hass.states.get("sensor.water_heater_appliance_energy_state") + assert state + assert state.state == "online" + + set_node_attribute(matter_node, 2, 152, 2, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.water_heater_appliance_energy_state") + assert state + assert state.state == "offline" + + +@pytest.mark.parametrize("node_fixture", ["pump"]) +async def test_pump( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test pump sensors.""" + # ControlMode + state = hass.states.get("sensor.mock_pump_control_mode") + assert state + assert state.state == "constant_temperature" + + set_node_attribute(matter_node, 1, 512, 33, 7) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.mock_pump_control_mode") + assert state + assert state.state == "automatic" + + # Speed + state = hass.states.get("sensor.mock_pump_rotation_speed") + assert state + assert state.state == "1000" + + set_node_attribute(matter_node, 1, 512, 20, 500) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.mock_pump_rotation_speed") + assert state + assert state.state == "500" diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index f294cd31a26..ecb65e625d9 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -8,7 +8,7 @@ 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 import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index 1b33f6a2fe2..5bd90ee1109 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_valve.py b/tests/components/matter/test_valve.py index 9c4429dda65..36ab34cb64e 100644 --- a/tests/components/matter/test_valve.py +++ b/tests/components/matter/test_valve.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_water_heater.py b/tests/components/matter/test_water_heater.py index eb2ea9eb40e..a674c87c24b 100644 --- a/tests/components/matter/test_water_heater.py +++ b/tests/components/matter/test_water_heater.py @@ -6,7 +6,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.components.water_heater import ( STATE_ECO, @@ -166,6 +166,32 @@ async def test_water_heater_boostmode( command=clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info), ) + # disable water_heater boostmode + await hass.services.async_call( + "water_heater", + "set_operation_mode", + { + "entity_id": "water_heater.water_heater", + "operation_mode": STATE_ECO, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 2 + 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 == 2 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=2, + command=clusters.WaterHeaterManagement.Commands.CancelBoost(), + ) + @pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) async def test_update_from_water_heater( diff --git a/tests/components/mealie/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr index 7587a7a55b7..48f5aaa7d75 100644 --- a/tests/components/mealie/snapshots/test_calendar.ambr +++ b/tests/components/mealie/snapshots/test_calendar.ambr @@ -191,6 +191,7 @@ 'original_name': 'Breakfast', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'breakfast', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_breakfast', @@ -244,6 +245,7 @@ 'original_name': 'Dinner', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dinner', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_dinner', @@ -297,6 +299,7 @@ 'original_name': 'Lunch', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lunch', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_lunch', @@ -350,6 +353,7 @@ 'original_name': 'Side', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'side', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_side', diff --git a/tests/components/mealie/snapshots/test_sensor.ambr b/tests/components/mealie/snapshots/test_sensor.ambr index 19219c01c1c..9dea508df39 100644 --- a/tests/components/mealie/snapshots/test_sensor.ambr +++ b/tests/components/mealie/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Categories', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'categories', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_categories', @@ -80,6 +81,7 @@ 'original_name': 'Recipes', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'recipes', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_recipes', @@ -131,6 +133,7 @@ 'original_name': 'Tags', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tags', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tags', @@ -182,6 +185,7 @@ 'original_name': 'Tools', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tools', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tools', @@ -233,6 +237,7 @@ 'original_name': 'Users', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'users', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_users', diff --git a/tests/components/mealie/snapshots/test_todo.ambr b/tests/components/mealie/snapshots/test_todo.ambr index 88c677de581..26cfb1ced68 100644 --- a/tests/components/mealie/snapshots/test_todo.ambr +++ b/tests/components/mealie/snapshots/test_todo.ambr @@ -27,6 +27,7 @@ 'original_name': 'Freezer', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'shopping_list', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_e9d78ff2-4b23-4b77-a3a8-464827100b46', @@ -75,6 +76,7 @@ 'original_name': 'Special groceries', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'shopping_list', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_f8438635-8211-4be8-80d0-0aa42e37a5f2', @@ -123,6 +125,7 @@ 'original_name': 'Supermarket', 'platform': 'mealie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'shopping_list', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_27edbaab-2ec6-441f-8490-0283ea77585f', diff --git a/tests/components/mealie/test_diagnostics.py b/tests/components/mealie/test_diagnostics.py index 88680da9784..43434d31107 100644 --- a/tests/components/mealie/test_diagnostics.py +++ b/tests/components/mealie/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/mealie/test_init.py b/tests/components/mealie/test_init.py index a45a67801df..7581363dee4 100644 --- a/tests/components/mealie/test_init.py +++ b/tests/components/mealie/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from aiomealie import About, MealieAuthenticationError, MealieConnectionError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.mealie.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 63668379490..57c55159bdc 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -11,7 +11,7 @@ from aiomealie import ( ) from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.mealie.const import ( ATTR_CONFIG_ENTRY_ID, diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py index 21fab6f875c..aa554720786 100644 --- a/tests/components/media_extractor/test_init.py +++ b/tests/components/media_extractor/test_init.py @@ -6,7 +6,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yt_dlp import DownloadError from homeassistant.components.media_extractor.const import ( diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index 8e7211183e7..4b08aa43158 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -8,7 +8,13 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_PLAY_MEDIA, + SERVICE_SEARCH_MEDIA, SERVICE_VOLUME_SET, + BrowseMedia, + MediaClass, + MediaType, + SearchMedia, intent as media_player_intent, ) from homeassistant.components.media_player.const import MediaPlayerEntityFeature @@ -19,6 +25,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, entity_registry as er, @@ -635,3 +642,153 @@ async def test_manual_pause_unpause( assert response.response_type == intent.IntentResponseType.ACTION_DONE assert len(calls) == 1 assert calls[0].data == {"entity_id": device_2.entity_id} + + +async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None: + """Test HassMediaSearchAndPlay intent for media players.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + } + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + + # Test successful search and play + search_result_item = BrowseMedia( + title="Test Track", + media_class=MediaClass.MUSIC, + media_content_type=MediaType.MUSIC, + media_content_id="library/artist/123/album/456/track/789", + can_play=True, + can_expand=False, + ) + + # Mock service calls + search_results = [search_result_item] + search_calls = async_mock_service( + hass, + DOMAIN, + SERVICE_SEARCH_MEDIA, + response={entity_id: SearchMedia(result=search_results)}, + ) + play_calls = async_mock_service(hass, DOMAIN, SERVICE_PLAY_MEDIA) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test query"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + # Response should contain a "media" slot with the matched item. + assert not response.speech + media = response.speech_slots.get("media") + assert media["title"] == "Test Track" + + assert len(search_calls) == 1 + search_call = search_calls[0] + assert search_call.domain == DOMAIN + assert search_call.service == SERVICE_SEARCH_MEDIA + assert search_call.data == { + "entity_id": entity_id, + "search_query": "test query", + } + + assert len(play_calls) == 1 + play_call = play_calls[0] + assert play_call.domain == DOMAIN + assert play_call.service == SERVICE_PLAY_MEDIA + assert play_call.data == { + "entity_id": entity_id, + "media_content_id": search_result_item.media_content_id, + "media_content_type": search_result_item.media_content_type, + } + + # Test no search results + search_results.clear() + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "another query"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + # A search failure is indicated by no "media" slot in the response. + assert not response.speech + assert "media" not in response.speech_slots + assert len(search_calls) == 2 # Search was called again + assert len(play_calls) == 1 # Play was not called again + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_IDLE, + attributes={}, + ) + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test query"}}, + ) + + # Test feature not supported (missing SEARCH_MEDIA) + hass.states.async_set( + entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PLAY_MEDIA}, + ) + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test query"}}, + ) + + # Test play media service errors + search_results.append(search_result_item) + hass.states.async_set( + entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.SEARCH_MEDIA}, + ) + + async_mock_service( + hass, + DOMAIN, + SERVICE_PLAY_MEDIA, + raise_exception=HomeAssistantError("Play failed"), + ) + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "play error query"}}, + ) + + # Test search service error + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + async_mock_service( + hass, + DOMAIN, + SERVICE_SEARCH_MEDIA, + raise_exception=HomeAssistantError("Search failed"), + ) + with pytest.raises(intent.IntentHandleError, match="Error searching media"): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "error query"}}, + ) diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index 2c2952068ee..1849fbc09ab 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -241,7 +241,7 @@ async def test_websocket_resolve_media( # Validate url is relative and signed. assert msg["result"]["url"][0] == "/" parsed = yarl.URL(msg["result"]["url"]) - assert parsed.path == getattr(media, "url") + assert parsed.path == media.url assert "authSig" in parsed.query with patch( diff --git a/tests/components/melcloud/test_diagnostics.py b/tests/components/melcloud/test_diagnostics.py index 32ec94a54d1..e1c498e8704 100644 --- a/tests/components/melcloud/test_diagnostics.py +++ b/tests/components/melcloud/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.melcloud.const import DOMAIN diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index b305d629a91..c93f741413d 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, diff --git a/tests/components/meteo_france/snapshots/test_sensor.ambr b/tests/components/meteo_france/snapshots/test_sensor.ambr index 35b6a9d19f7..2d048112bbb 100644 --- a/tests/components/meteo_france/snapshots/test_sensor.ambr +++ b/tests/components/meteo_france/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': '32 Weather alert', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '32 Weather alert', @@ -82,6 +83,7 @@ 'original_name': 'La Clusaz Cloud cover', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_cloud', @@ -132,6 +134,7 @@ 'original_name': 'La Clusaz Daily original condition', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_daily_original_condition', @@ -174,12 +177,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'La Clusaz Daily precipitation', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_precipitation', @@ -230,6 +237,7 @@ 'original_name': 'La Clusaz Freeze chance', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_freeze_chance', @@ -282,6 +290,7 @@ 'original_name': 'La Clusaz Humidity', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_humidity', @@ -333,6 +342,7 @@ 'original_name': 'La Clusaz Original condition', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_original_condition', @@ -377,12 +387,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'La Clusaz Pressure', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_pressure', @@ -434,6 +448,7 @@ 'original_name': 'La Clusaz Rain chance', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_rain_chance', @@ -484,6 +499,7 @@ 'original_name': 'La Clusaz Snow chance', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_snow_chance', @@ -530,12 +546,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'La Clusaz Temperature', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_temperature', @@ -587,6 +607,7 @@ 'original_name': 'La Clusaz UV', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_uv', @@ -633,12 +654,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': 'mdi:weather-windy-variant', 'original_name': 'La Clusaz Wind gust', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_wind_gust', @@ -687,12 +712,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'La Clusaz Wind speed', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '45.90417,6.42306_wind_speed', @@ -744,6 +773,7 @@ 'original_name': 'Meudon Next rain', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '48.807166,2.239895_next_rain', diff --git a/tests/components/meteo_france/snapshots/test_weather.ambr b/tests/components/meteo_france/snapshots/test_weather.ambr index d5e03c95de2..4fdc22cd427 100644 --- a/tests/components/meteo_france/snapshots/test_weather.ambr +++ b/tests/components/meteo_france/snapshots/test_weather.ambr @@ -27,6 +27,7 @@ 'original_name': 'La Clusaz', 'platform': 'meteo_france', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '45.90417,6.42306', diff --git a/tests/components/metoffice/conftest.py b/tests/components/metoffice/conftest.py index 83c7e7853f7..dc64cc8dfb1 100644 --- a/tests/components/metoffice/conftest.py +++ b/tests/components/metoffice/conftest.py @@ -9,10 +9,9 @@ import pytest @pytest.fixture def mock_simple_manager_fail(): """Mock datapoint Manager with default values for testing in config_flow.""" - with patch("datapoint.Manager") as mock_manager: + with patch("datapoint.Manager.Manager") as mock_manager: instance = mock_manager.return_value - instance.get_nearest_forecast_site.side_effect = APIException() - instance.get_forecast_for_site.side_effect = APIException() + instance.get_forecast = APIException() instance.latitude = None instance.longitude = None instance.site = None diff --git a/tests/components/metoffice/const.py b/tests/components/metoffice/const.py index 8fe1b42ca59..59061f12ddc 100644 --- a/tests/components/metoffice/const.py +++ b/tests/components/metoffice/const.py @@ -3,7 +3,7 @@ from homeassistant.components.metoffice.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -TEST_DATETIME_STRING = "2020-04-25T12:00:00+00:00" +TEST_DATETIME_STRING = "2024-11-23T12:00:00+00:00" TEST_API_KEY = "test-metoffice-api-key" @@ -34,31 +34,21 @@ METOFFICE_CONFIG_KINGSLYNN = { } KINGSLYNN_SENSOR_RESULTS = { - "weather": ("weather", "sunny"), - "visibility": ("visibility", "Very Good"), - "visibility_distance": ("visibility_distance", "20-40"), - "temperature": ("temperature", "14"), - "feels_like_temperature": ("feels_like_temperature", "13"), - "uv": ("uv_index", "6"), - "precipitation": ("probability_of_precipitation", "0"), - "wind_direction": ("wind_direction", "E"), - "wind_gust": ("wind_gust", "7"), - "wind_speed": ("wind_speed", "2"), - "humidity": ("humidity", "60"), + "weather": "rainy", + "temperature": "7.9", + "uv_index": "1", + "probability_of_precipitation": "67", + "pressure": "998.20", + "wind_speed": "22.21", } WAVERTREE_SENSOR_RESULTS = { - "weather": ("weather", "sunny"), - "visibility": ("visibility", "Good"), - "visibility_distance": ("visibility_distance", "10-20"), - "temperature": ("temperature", "17"), - "feels_like_temperature": ("feels_like_temperature", "14"), - "uv": ("uv_index", "5"), - "precipitation": ("probability_of_precipitation", "0"), - "wind_direction": ("wind_direction", "SSE"), - "wind_gust": ("wind_gust", "16"), - "wind_speed": ("wind_speed", "9"), - "humidity": ("humidity", "50"), + "weather": "rainy", + "temperature": "9.3", + "uv_index": "1", + "probability_of_precipitation": "61", + "pressure": "987.50", + "wind_speed": "17.60", } DEVICE_KEY_KINGSLYNN = {(DOMAIN, TEST_COORDINATES_KINGSLYNN)} diff --git a/tests/components/metoffice/fixtures/metoffice.json b/tests/components/metoffice/fixtures/metoffice.json index 68ba02b5429..70ed76e779c 100644 --- a/tests/components/metoffice/fixtures/metoffice.json +++ b/tests/components/metoffice/fixtures/metoffice.json @@ -23,1731 +23,4134 @@ ] } }, - "wavertree_hourly": { - "SiteRep": { - "Wx": { - "Param": [ - { - "name": "F", - "units": "C", - "$": "Feels Like Temperature" - }, - { - "name": "G", - "units": "mph", - "$": "Wind Gust" - }, - { - "name": "H", - "units": "%", - "$": "Screen Relative Humidity" - }, - { - "name": "T", - "units": "C", - "$": "Temperature" - }, - { - "name": "V", - "units": "", - "$": "Visibility" - }, - { - "name": "D", - "units": "compass", - "$": "Wind Direction" - }, - { - "name": "S", - "units": "mph", - "$": "Wind Speed" - }, - { - "name": "U", - "units": "", - "$": "Max UV Index" - }, - { - "name": "W", - "units": "", - "$": "Weather Type" - }, - { - "name": "Pp", - "units": "%", - "$": "Precipitation Probability" - } - ] - }, - "DV": { - "dataDate": "2020-04-25T08:00:00Z", - "type": "Forecast", - "Location": { - "i": "354107", - "lat": "53.3986", - "lon": "-2.9256", - "name": "WAVERTREE", - "country": "ENGLAND", - "continent": "EUROPE", - "elevation": "47.0", - "Period": [ - { - "type": "Day", - "value": "2020-04-25Z", - "Rep": [ - { - "D": "SE", - "F": "7", - "G": "25", - "H": "63", - "Pp": "0", - "S": "9", - "T": "9", - "V": "VG", - "W": "0", - "U": "0", - "$": "180" - }, - { - "D": "ESE", - "F": "4", - "G": "22", - "H": "76", - "Pp": "0", - "S": "11", - "T": "7", - "V": "GO", - "W": "1", - "U": "1", - "$": "360" - }, - { - "D": "SSE", - "F": "8", - "G": "18", - "H": "70", - "Pp": "0", - "S": "9", - "T": "10", - "V": "MO", - "W": "1", - "U": "3", - "$": "540" - }, - { - "D": "SSE", - "F": "14", - "G": "16", - "H": "50", - "Pp": "0", - "S": "9", - "T": "17", - "V": "GO", - "W": "1", - "U": "5", - "$": "720" - }, - { - "D": "S", - "F": "17", - "G": "9", - "H": "43", - "Pp": "1", - "S": "4", - "T": "19", - "V": "GO", - "W": "1", - "U": "2", - "$": "900" - }, - { - "D": "WNW", - "F": "15", - "G": "13", - "H": "55", - "Pp": "2", - "S": "7", - "T": "17", - "V": "GO", - "W": "3", - "U": "1", - "$": "1080" - }, - { - "D": "NW", - "F": "14", - "G": "7", - "H": "64", - "Pp": "1", - "S": "2", - "T": "14", - "V": "GO", - "W": "2", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-26Z", - "Rep": [ - { - "D": "WSW", - "F": "13", - "G": "4", - "H": "73", - "Pp": "1", - "S": "2", - "T": "13", - "V": "GO", - "W": "2", - "U": "0", - "$": "0" - }, - { - "D": "WNW", - "F": "12", - "G": "9", - "H": "77", - "Pp": "2", - "S": "4", - "T": "12", - "V": "GO", - "W": "2", - "U": "0", - "$": "180" - }, - - { - "D": "NW", - "F": "10", - "G": "9", - "H": "82", - "Pp": "5", - "S": "4", - "T": "11", - "V": "MO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "WNW", - "F": "11", - "G": "7", - "H": "79", - "Pp": "5", - "S": "4", - "T": "12", - "V": "MO", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "WNW", - "F": "10", - "G": "18", - "H": "78", - "Pp": "6", - "S": "9", - "T": "12", - "V": "MO", - "W": "7", - "U": "4", - "$": "720" - }, - { - "D": "NW", - "F": "10", - "G": "18", - "H": "71", - "Pp": "5", - "S": "9", - "T": "12", - "V": "GO", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "NW", - "F": "9", - "G": "16", - "H": "68", - "Pp": "9", - "S": "9", - "T": "11", - "V": "VG", - "W": "7", - "U": "1", - "$": "1080" - }, - { - "D": "NW", - "F": "8", - "G": "11", - "H": "68", - "Pp": "9", - "S": "7", - "T": "10", - "V": "VG", - "W": "8", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-27Z", - "Rep": [ - { - "D": "WNW", - "F": "8", - "G": "9", - "H": "72", - "Pp": "11", - "S": "4", - "T": "9", - "V": "VG", - "W": "8", - "U": "0", - "$": "0" - }, - { - "D": "WNW", - "F": "7", - "G": "11", - "H": "77", - "Pp": "12", - "S": "7", - "T": "8", - "V": "VG", - "W": "7", - "U": "0", - "$": "180" - }, - { - "D": "NW", - "F": "7", - "G": "9", - "H": "80", - "Pp": "14", - "S": "4", - "T": "8", - "V": "GO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "NW", - "F": "7", - "G": "18", - "H": "73", - "Pp": "6", - "S": "9", - "T": "9", - "V": "VG", - "W": "3", - "U": "2", - "$": "540" - }, - { - "D": "NW", - "F": "8", - "G": "20", - "H": "59", - "Pp": "4", - "S": "9", - "T": "10", - "V": "VG", - "W": "3", - "U": "3", - "$": "720" - }, - { - "D": "NW", - "F": "8", - "G": "20", - "H": "58", - "Pp": "1", - "S": "9", - "T": "10", - "V": "VG", - "W": "1", - "U": "2", - "$": "900" - }, - { - "D": "NW", - "F": "8", - "G": "16", - "H": "57", - "Pp": "1", - "S": "7", - "T": "10", - "V": "VG", - "W": "1", - "U": "1", - "$": "1080" - }, - { - "D": "NW", - "F": "8", - "G": "11", - "H": "67", - "Pp": "1", - "S": "4", - "T": "9", - "V": "VG", - "W": "0", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-28Z", - "Rep": [ - { - "D": "NNW", - "F": "7", - "G": "7", - "H": "80", - "Pp": "2", - "S": "4", - "T": "8", - "V": "VG", - "W": "0", - "U": "0", - "$": "0" - }, - { - "D": "W", - "F": "6", - "G": "7", - "H": "86", - "Pp": "3", - "S": "4", - "T": "7", - "V": "GO", - "W": "0", - "U": "0", - "$": "180" - }, - { - "D": "S", - "F": "5", - "G": "9", - "H": "86", - "Pp": "5", - "S": "4", - "T": "6", - "V": "GO", - "W": "1", - "U": "1", - "$": "360" - }, - { - "D": "ENE", - "F": "7", - "G": "13", - "H": "72", - "Pp": "6", - "S": "7", - "T": "9", - "V": "GO", - "W": "3", - "U": "3", - "$": "540" - }, - { - "D": "ENE", - "F": "10", - "G": "16", - "H": "57", - "Pp": "10", - "S": "7", - "T": "11", - "V": "GO", - "W": "7", - "U": "4", - "$": "720" - }, - { - "D": "N", - "F": "11", - "G": "16", - "H": "58", - "Pp": "10", - "S": "7", - "T": "12", - "V": "GO", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "N", - "F": "10", - "G": "16", - "H": "63", - "Pp": "10", - "S": "7", - "T": "11", - "V": "VG", - "W": "7", - "U": "1", - "$": "1080" - }, - { - "D": "NNE", - "F": "9", - "G": "11", - "H": "72", - "Pp": "9", - "S": "4", - "T": "10", - "V": "VG", - "W": "7", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-29Z", - "Rep": [ - { - "D": "E", - "F": "8", - "G": "9", - "H": "79", - "Pp": "6", - "S": "4", - "T": "9", - "V": "VG", - "W": "7", - "U": "0", - "$": "0" - }, - { - "D": "SSE", - "F": "7", - "G": "11", - "H": "81", - "Pp": "3", - "S": "7", - "T": "8", - "V": "GO", - "W": "2", - "U": "0", - "$": "180" - }, - { - "D": "SE", - "F": "5", - "G": "16", - "H": "86", - "Pp": "9", - "S": "9", - "T": "8", - "V": "GO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "SE", - "F": "8", - "G": "22", - "H": "74", - "Pp": "12", - "S": "11", - "T": "10", - "V": "GO", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "SE", - "F": "10", - "G": "27", - "H": "72", - "Pp": "47", - "S": "13", - "T": "12", - "V": "GO", - "W": "12", - "U": "3", - "$": "720" - }, - { - "D": "SSE", - "F": "10", - "G": "29", - "H": "73", - "Pp": "59", - "S": "13", - "T": "13", - "V": "GO", - "W": "14", - "U": "2", - "$": "900" - }, - { - "D": "SSE", - "F": "10", - "G": "20", - "H": "69", - "Pp": "39", - "S": "11", - "T": "12", - "V": "VG", - "W": "10", - "U": "1", - "$": "1080" - }, - { - "D": "SSE", - "F": "9", - "G": "22", - "H": "79", - "Pp": "19", - "S": "13", - "T": "11", - "V": "GO", - "W": "7", - "U": "0", - "$": "1260" - } - ] - } - ] - } - } - } - }, "wavertree_daily": { - "SiteRep": { - "Wx": { - "Param": [ - { - "name": "FDm", - "units": "C", - "$": "Feels Like Day Maximum Temperature" + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-2.9256, 53.3986, 47] + }, + "properties": { + "location": { + "name": "Wavertree" }, - { - "name": "FNm", - "units": "C", - "$": "Feels Like Night Minimum Temperature" - }, - { - "name": "Dm", - "units": "C", - "$": "Day Maximum Temperature" - }, - { - "name": "Nm", - "units": "C", - "$": "Night Minimum Temperature" - }, - { - "name": "Gn", - "units": "mph", - "$": "Wind Gust Noon" - }, - { - "name": "Gm", - "units": "mph", - "$": "Wind Gust Midnight" - }, - { - "name": "Hn", - "units": "%", - "$": "Screen Relative Humidity Noon" - }, - { - "name": "Hm", - "units": "%", - "$": "Screen Relative Humidity Midnight" - }, - { - "name": "V", - "units": "", - "$": "Visibility" - }, - { - "name": "D", - "units": "compass", - "$": "Wind Direction" - }, - { - "name": "S", - "units": "mph", - "$": "Wind Speed" - }, - { - "name": "U", - "units": "", - "$": "Max UV Index" - }, - { - "name": "W", - "units": "", - "$": "Weather Type" - }, - { - "name": "PPd", - "units": "%", - "$": "Precipitation Probability Day" - }, - { - "name": "PPn", - "units": "%", - "$": "Precipitation Probability Night" - } - ] - }, - "DV": { - "dataDate": "2020-04-25T08:00:00Z", - "type": "Forecast", - "Location": { - "i": "354107", - "lat": "53.3986", - "lon": "-2.9256", - "name": "WAVERTREE", - "country": "ENGLAND", - "continent": "EUROPE", - "elevation": "47.0", - "Period": [ + "requestPointDistance": 1975.3601, + "modelRunDate": "2024-11-23T12:00Z", + "timeSeries": [ { - "type": "Day", - "value": "2020-04-25Z", - "Rep": [ - { - "D": "SSE", - "Gn": "16", - "Hn": "50", - "PPd": "2", - "S": "9", - "V": "GO", - "Dm": "19", - "FDm": "18", - "W": "1", - "U": "5", - "$": "Day" - }, - { - "D": "WSW", - "Gm": "4", - "Hm": "73", - "PPn": "2", - "S": "2", - "V": "GO", - "Nm": "11", - "FNm": "11", - "W": "2", - "$": "Night" - } - ] + "time": "2024-11-22T00:00Z", + "midday10MWindSpeed": 6.38, + "midnight10MWindSpeed": 2.78, + "midday10MWindDirection": 261, + "midnight10MWindDirection": 155, + "midday10MWindGust": 9.77, + "midnight10MWindGust": 8.75, + "middayVisibility": 29980, + "midnightVisibility": 18024, + "middayRelativeHumidity": 73.47, + "midnightRelativeHumidity": 86.1, + "middayMslp": 100790, + "midnightMslp": 101020, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 7.17, + "nightMinScreenTemperature": 2, + "dayUpperBoundMaxTemp": 7.78, + "nightUpperBoundMinTemp": 3.84, + "dayLowerBoundMaxTemp": 4.64, + "nightLowerBoundMinTemp": 1.18, + "nightMinFeelsLikeTemp": -3.07, + "dayUpperBoundMaxFeelsLikeTemp": 4.39, + "nightUpperBoundMinFeelsLikeTemp": -1.33, + "dayLowerBoundMaxFeelsLikeTemp": 2.49, + "nightLowerBoundMinFeelsLikeTemp": -4.04, + "nightProbabilityOfPrecipitation": 95, + "nightProbabilityOfSnow": 5, + "nightProbabilityOfHeavySnow": 0, + "nightProbabilityOfRain": 93, + "nightProbabilityOfHeavyRain": 90, + "nightProbabilityOfHail": 20, + "nightProbabilityOfSferics": 9 }, { - "type": "Day", - "value": "2020-04-26Z", - "Rep": [ - { - "D": "WNW", - "Gn": "18", - "Hn": "78", - "PPd": "9", - "S": "9", - "V": "MO", - "Dm": "13", - "FDm": "11", - "W": "7", - "U": "4", - "$": "Day" - }, - { - "D": "WNW", - "Gm": "9", - "Hm": "72", - "PPn": "12", - "S": "4", - "V": "VG", - "Nm": "8", - "FNm": "7", - "W": "8", - "$": "Night" - } - ] + "time": "2024-11-23T00:00Z", + "midday10MWindSpeed": 7.87, + "midnight10MWindSpeed": 7.44, + "midday10MWindDirection": 176, + "midnight10MWindDirection": 171, + "midday10MWindGust": 15.43, + "midnight10MWindGust": 14.08, + "middayVisibility": 5106, + "midnightVisibility": 39734, + "middayRelativeHumidity": 95.13, + "midnightRelativeHumidity": 86.99, + "middayMslp": 98750, + "midnightMslp": 98490, + "maxUvIndex": 1, + "daySignificantWeatherCode": 12, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 12.56, + "nightMinScreenTemperature": 11.46, + "dayUpperBoundMaxTemp": 14.48, + "nightUpperBoundMinTemp": 13.92, + "dayLowerBoundMaxTemp": 11.63, + "nightLowerBoundMinTemp": 10.7, + "dayMaxFeelsLikeTemp": 9.81, + "nightMinFeelsLikeTemp": 9.53, + "dayUpperBoundMaxFeelsLikeTemp": 12.68, + "nightUpperBoundMinFeelsLikeTemp": 11.39, + "dayLowerBoundMaxFeelsLikeTemp": 9.81, + "nightLowerBoundMinFeelsLikeTemp": 9.53, + "dayProbabilityOfPrecipitation": 65, + "nightProbabilityOfPrecipitation": 74, + "dayProbabilityOfSnow": 3, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 65, + "nightProbabilityOfRain": 74, + "dayProbabilityOfHeavyRain": 41, + "nightProbabilityOfHeavyRain": 73, + "dayProbabilityOfHail": 3, + "nightProbabilityOfHail": 15, + "dayProbabilityOfSferics": 2, + "nightProbabilityOfSferics": 12 }, { - "type": "Day", - "value": "2020-04-27Z", - "Rep": [ - { - "D": "NW", - "Gn": "20", - "Hn": "59", - "PPd": "14", - "S": "9", - "V": "VG", - "Dm": "11", - "FDm": "8", - "W": "3", - "U": "3", - "$": "Day" - }, - { - "D": "NNW", - "Gm": "7", - "Hm": "80", - "PPn": "3", - "S": "4", - "V": "VG", - "Nm": "6", - "FNm": "5", - "W": "0", - "$": "Night" - } - ] + "time": "2024-11-24T00:00Z", + "midday10MWindSpeed": 6.65, + "midnight10MWindSpeed": 7.33, + "midday10MWindDirection": 203, + "midnight10MWindDirection": 211, + "midday10MWindGust": 11.85, + "midnight10MWindGust": 13.11, + "middayVisibility": 36358, + "midnightVisibility": 51563, + "middayRelativeHumidity": 70.26, + "midnightRelativeHumidity": 72.97, + "middayMslp": 98748, + "midnightMslp": 98712, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 12.7, + "nightMinScreenTemperature": 8.21, + "dayUpperBoundMaxTemp": 15.19, + "nightUpperBoundMinTemp": 10.67, + "dayLowerBoundMaxTemp": 11.87, + "nightLowerBoundMinTemp": 7.03, + "dayMaxFeelsLikeTemp": 9.17, + "nightMinFeelsLikeTemp": 4.84, + "dayUpperBoundMaxFeelsLikeTemp": 12.63, + "nightUpperBoundMinFeelsLikeTemp": 7.25, + "dayLowerBoundMaxFeelsLikeTemp": 9.17, + "nightLowerBoundMinFeelsLikeTemp": 3.81, + "dayProbabilityOfPrecipitation": 26, + "nightProbabilityOfPrecipitation": 23, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 26, + "nightProbabilityOfRain": 23, + "dayProbabilityOfHeavyRain": 13, + "nightProbabilityOfHeavyRain": 16, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 3, + "dayProbabilityOfSferics": 3, + "nightProbabilityOfSferics": 2 }, { - "type": "Day", - "value": "2020-04-28Z", - "Rep": [ - { - "D": "ENE", - "Gn": "16", - "Hn": "57", - "PPd": "10", - "S": "7", - "V": "GO", - "Dm": "12", - "FDm": "11", - "W": "7", - "U": "4", - "$": "Day" - }, - { - "D": "E", - "Gm": "9", - "Hm": "79", - "PPn": "9", - "S": "4", - "V": "VG", - "Nm": "7", - "FNm": "6", - "W": "7", - "$": "Night" - } - ] + "time": "2024-11-25T00:00Z", + "midday10MWindSpeed": 8.52, + "midnight10MWindSpeed": 8.12, + "midday10MWindDirection": 251, + "midnight10MWindDirection": 262, + "midday10MWindGust": 14.49, + "midnight10MWindGust": 13.33, + "middayVisibility": 32255, + "midnightVisibility": 36209, + "middayRelativeHumidity": 68.89, + "midnightRelativeHumidity": 72.82, + "middayMslp": 99488, + "midnightMslp": 100481, + "maxUvIndex": 1, + "daySignificantWeatherCode": 3, + "nightSignificantWeatherCode": 2, + "dayMaxScreenTemperature": 9.81, + "nightMinScreenTemperature": 7.71, + "dayUpperBoundMaxTemp": 10.98, + "nightUpperBoundMinTemp": 9.31, + "dayLowerBoundMaxTemp": 8.42, + "nightLowerBoundMinTemp": 4.42, + "dayMaxFeelsLikeTemp": 5.33, + "nightMinFeelsLikeTemp": 4.19, + "dayUpperBoundMaxFeelsLikeTemp": 7.12, + "nightUpperBoundMinFeelsLikeTemp": 5.29, + "dayLowerBoundMaxFeelsLikeTemp": 4.86, + "nightLowerBoundMinFeelsLikeTemp": 3.1, + "dayProbabilityOfPrecipitation": 5, + "nightProbabilityOfPrecipitation": 6, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 5, + "nightProbabilityOfRain": 6, + "dayProbabilityOfHeavyRain": 3, + "nightProbabilityOfHeavyRain": 5, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 1, + "nightProbabilityOfSferics": 1 }, { - "type": "Day", - "value": "2020-04-29Z", - "Rep": [ - { - "D": "SE", - "Gn": "27", - "Hn": "72", - "PPd": "59", - "S": "13", - "V": "GO", - "Dm": "13", - "FDm": "10", - "W": "12", - "U": "3", - "$": "Day" - }, - { - "D": "SSE", - "Gm": "18", - "Hm": "85", - "PPn": "19", - "S": "11", - "V": "VG", - "Nm": "8", - "FNm": "6", - "W": "7", - "$": "Night" - } - ] + "time": "2024-11-26T00:00Z", + "midday10MWindSpeed": 5.68, + "midnight10MWindSpeed": 3.17, + "midday10MWindDirection": 265, + "midnight10MWindDirection": 74, + "midday10MWindGust": 9.58, + "midnight10MWindGust": 5.42, + "middayVisibility": 34027, + "midnightVisibility": 12383, + "middayRelativeHumidity": 70.41, + "midnightRelativeHumidity": 89.82, + "middayMslp": 101293, + "midnightMslp": 101390, + "maxUvIndex": 1, + "daySignificantWeatherCode": 3, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 8.72, + "nightMinScreenTemperature": 3.76, + "dayUpperBoundMaxTemp": 10.14, + "nightUpperBoundMinTemp": 7.47, + "dayLowerBoundMaxTemp": 6.46, + "nightLowerBoundMinTemp": -0.43, + "dayMaxFeelsLikeTemp": 5.9, + "nightMinFeelsLikeTemp": 1.31, + "dayUpperBoundMaxFeelsLikeTemp": 7.37, + "nightUpperBoundMinFeelsLikeTemp": 4.37, + "dayLowerBoundMaxFeelsLikeTemp": 3.99, + "nightLowerBoundMinFeelsLikeTemp": -3.09, + "dayProbabilityOfPrecipitation": 6, + "nightProbabilityOfPrecipitation": 44, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 1, + "dayProbabilityOfRain": 6, + "nightProbabilityOfRain": 44, + "dayProbabilityOfHeavyRain": 5, + "nightProbabilityOfHeavyRain": 24, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 2, + "dayProbabilityOfSferics": 1, + "nightProbabilityOfSferics": 1 + }, + { + "time": "2024-11-27T00:00Z", + "midday10MWindSpeed": 5.15, + "midnight10MWindSpeed": 3.29, + "midday10MWindDirection": 8, + "midnight10MWindDirection": 31, + "midday10MWindGust": 8.94, + "midnight10MWindGust": 5.54, + "middayVisibility": 25011, + "midnightVisibility": 31513, + "middayRelativeHumidity": 81.23, + "midnightRelativeHumidity": 86.67, + "middayMslp": 101439, + "midnightMslp": 102175, + "maxUvIndex": 1, + "daySignificantWeatherCode": 10, + "nightSignificantWeatherCode": 0, + "dayMaxScreenTemperature": 6.66, + "nightMinScreenTemperature": 2.36, + "dayUpperBoundMaxTemp": 11.14, + "nightUpperBoundMinTemp": 7.25, + "dayLowerBoundMaxTemp": 3.03, + "nightLowerBoundMinTemp": -3.02, + "dayMaxFeelsLikeTemp": 3.31, + "nightMinFeelsLikeTemp": 0.18, + "dayUpperBoundMaxFeelsLikeTemp": 9.03, + "nightUpperBoundMinFeelsLikeTemp": 3.85, + "dayLowerBoundMaxFeelsLikeTemp": 1.04, + "nightLowerBoundMinFeelsLikeTemp": -7.6, + "dayProbabilityOfPrecipitation": 43, + "nightProbabilityOfPrecipitation": 9, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 3, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 43, + "nightProbabilityOfRain": 8, + "dayProbabilityOfHeavyRain": 24, + "nightProbabilityOfHeavyRain": 7, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 3, + "nightProbabilityOfSferics": 1 + }, + { + "time": "2024-11-28T00:00Z", + "midday10MWindSpeed": 3.51, + "midnight10MWindSpeed": 5.57, + "midday10MWindDirection": 104, + "midnight10MWindDirection": 131, + "midday10MWindGust": 6.21, + "midnight10MWindGust": 9.21, + "middayVisibility": 28173, + "midnightVisibility": 33839, + "middayRelativeHumidity": 85.35, + "midnightRelativeHumidity": 86.07, + "middayMslp": 102512, + "midnightMslp": 102382, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 5.73, + "nightMinScreenTemperature": 3.79, + "dayUpperBoundMaxTemp": 9.42, + "nightUpperBoundMinTemp": 8.18, + "dayLowerBoundMaxTemp": 1.26, + "nightLowerBoundMinTemp": -1.91, + "dayMaxFeelsLikeTemp": 2.95, + "nightMinFeelsLikeTemp": 1.63, + "dayUpperBoundMaxFeelsLikeTemp": 7.21, + "nightUpperBoundMinFeelsLikeTemp": 4.13, + "dayLowerBoundMaxFeelsLikeTemp": -0.81, + "nightLowerBoundMinFeelsLikeTemp": -5.94, + "dayProbabilityOfPrecipitation": 9, + "nightProbabilityOfPrecipitation": 9, + "dayProbabilityOfSnow": 2, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 9, + "nightProbabilityOfRain": 9, + "dayProbabilityOfHeavyRain": 3, + "nightProbabilityOfHeavyRain": 3, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 0 + }, + { + "time": "2024-11-29T00:00Z", + "midday10MWindSpeed": 6.39, + "midnight10MWindSpeed": 5.59, + "midday10MWindDirection": 137, + "midnight10MWindDirection": 151, + "midday10MWindGust": 10.72, + "midnight10MWindGust": 9.21, + "middayVisibility": 34870, + "midnightVisibility": 31318, + "middayRelativeHumidity": 83.78, + "midnightRelativeHumidity": 87.71, + "middayMslp": 101985, + "midnightMslp": 101688, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 8.21, + "nightMinScreenTemperature": 7.04, + "dayUpperBoundMaxTemp": 12.62, + "nightUpperBoundMinTemp": 10.76, + "dayLowerBoundMaxTemp": 4.15, + "nightLowerBoundMinTemp": -1.9, + "dayMaxFeelsLikeTemp": 4.88, + "nightMinFeelsLikeTemp": 4.95, + "dayUpperBoundMaxFeelsLikeTemp": 10.74, + "nightUpperBoundMinFeelsLikeTemp": 9.04, + "dayLowerBoundMaxFeelsLikeTemp": 0.63, + "nightLowerBoundMinFeelsLikeTemp": -6.49, + "dayProbabilityOfPrecipitation": 11, + "nightProbabilityOfPrecipitation": 13, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 11, + "nightProbabilityOfRain": 13, + "dayProbabilityOfHeavyRain": 4, + "nightProbabilityOfHeavyRain": 6, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 1 } ] } } - } + ], + "parameters": [ + { + "daySignificantWeatherCode": { + "type": "Parameter", + "description": "Day Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://datahub.metoffice.gov.uk/", + "type": "1" + } + } + }, + "midnightRelativeHumidity": { + "type": "Parameter", + "description": "Relative Humidity at Local Midnight", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightProbabilityOfHeavyRain": { + "type": "Parameter", + "description": "Probability of Heavy Rain During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "midnight10MWindSpeed": { + "type": "Parameter", + "description": "10m Wind Speed at Local Midnight", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "nightUpperBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Night Minimum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightUpperBoundMinTemp": { + "type": "Parameter", + "description": "Upper Bound on Night Minimum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "midnightVisibility": { + "type": "Parameter", + "description": "Visibility at Local Midnight", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "dayUpperBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Day Maximum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfRain": { + "type": "Parameter", + "description": "Probability of Rain During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "midday10MWindDirection": { + "type": "Parameter", + "description": "10m Wind Direction at Local Midday", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "nightLowerBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Night Minimum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfHail": { + "type": "Parameter", + "description": "Probability of Hail During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "middayMslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure at Local Midday", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "dayProbabilityOfHeavySnow": { + "type": "Parameter", + "description": "Probability of Heavy Snow During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightProbabilityOfPrecipitation": { + "type": "Parameter", + "description": "Probability of Precipitation During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayProbabilityOfHail": { + "type": "Parameter", + "description": "Probability of Hail During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayProbabilityOfRain": { + "type": "Parameter", + "description": "Probability of Rain During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "midday10MWindSpeed": { + "type": "Parameter", + "description": "10m Wind Speed at Local Midday", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "midday10MWindGust": { + "type": "Parameter", + "description": "10m Wind Gust Speed at Local Midday", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "middayVisibility": { + "type": "Parameter", + "description": "Visibility at Local Midday", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "midnight10MWindGust": { + "type": "Parameter", + "description": "10m Wind Gust Speed at Local Midnight", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "midnightMslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure at Local Midnight", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "dayProbabilityOfSferics": { + "type": "Parameter", + "description": "Probability of Sferics During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightSignificantWeatherCode": { + "type": "Parameter", + "description": "Night Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://datahub.metoffice.gov.uk/", + "type": "1" + } + } + }, + "dayProbabilityOfPrecipitation": { + "type": "Parameter", + "description": "Probability of Precipitation During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayProbabilityOfHeavyRain": { + "type": "Parameter", + "description": "Probability of Heavy Rain During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayMaxScreenTemperature": { + "type": "Parameter", + "description": "Day Maximum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightMinScreenTemperature": { + "type": "Parameter", + "description": "Night Minimum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "midnight10MWindDirection": { + "type": "Parameter", + "description": "10m Wind Direction at Local Midnight", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "maxUvIndex": { + "type": "Parameter", + "description": "Day Maximum UV Index", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "1" + } + } + }, + "dayProbabilityOfSnow": { + "type": "Parameter", + "description": "Probability of Snow During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightProbabilityOfSnow": { + "type": "Parameter", + "description": "Probability of Snow During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayLowerBoundMaxTemp": { + "type": "Parameter", + "description": "Lower Bound on Day Maximum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfHeavySnow": { + "type": "Parameter", + "description": "Probability of Heavy Snow During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayLowerBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Day Maximum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "dayUpperBoundMaxTemp": { + "type": "Parameter", + "description": "Upper Bound on Day Maximum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "dayMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Day Maximum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "middayRelativeHumidity": { + "type": "Parameter", + "description": "Relative Humidity at Local Midday", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightLowerBoundMinTemp": { + "type": "Parameter", + "description": "Lower Bound on Night Minimum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Night Minimum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfSferics": { + "type": "Parameter", + "description": "Probability of Sferics During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + } + } + ] }, - "kingslynn_hourly": { - "SiteRep": { - "Wx": { - "Param": [ - { - "name": "F", - "units": "C", - "$": "Feels Like Temperature" + "wavertree_hourly": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-2.9256, 53.3986, 47] + }, + "properties": { + "location": { + "name": "Wavertree" }, - { - "name": "G", - "units": "mph", - "$": "Wind Gust" - }, - { - "name": "H", - "units": "%", - "$": "Screen Relative Humidity" - }, - { - "name": "T", - "units": "C", - "$": "Temperature" - }, - { - "name": "V", - "units": "", - "$": "Visibility" - }, - { - "name": "D", - "units": "compass", - "$": "Wind Direction" - }, - { - "name": "S", - "units": "mph", - "$": "Wind Speed" - }, - { - "name": "U", - "units": "", - "$": "Max UV Index" - }, - { - "name": "W", - "units": "", - "$": "Weather Type" - }, - { - "name": "Pp", - "units": "%", - "$": "Precipitation Probability" - } - ] - }, - "DV": { - "dataDate": "2020-04-25T08:00:00Z", - "type": "Forecast", - "Location": { - "i": "322380", - "lat": "52.7561", - "lon": "0.4019", - "name": "KING'S LYNN", - "country": "ENGLAND", - "continent": "EUROPE", - "elevation": "5.0", - "Period": [ + "requestPointDistance": 1975.3601, + "modelRunDate": "2024-11-23T12:00Z", + "timeSeries": [ { - "type": "Day", - "value": "2020-04-25Z", - "Rep": [ - { - "D": "SSE", - "F": "4", - "G": "9", - "H": "88", - "Pp": "7", - "S": "9", - "T": "7", - "V": "GO", - "W": "8", - "U": "0", - "$": "180" - }, - { - "D": "ESE", - "F": "5", - "G": "7", - "H": "86", - "Pp": "9", - "S": "4", - "T": "7", - "V": "GO", - "W": "8", - "U": "1", - "$": "360" - }, - { - "D": "ESE", - "F": "8", - "G": "4", - "H": "75", - "Pp": "9", - "S": "4", - "T": "9", - "V": "VG", - "W": "8", - "U": "3", - "$": "540" - }, - { - "D": "E", - "F": "13", - "G": "7", - "H": "60", - "Pp": "0", - "S": "2", - "T": "14", - "V": "VG", - "W": "1", - "U": "6", - "$": "720" - }, - { - "D": "NNW", - "F": "14", - "G": "9", - "H": "57", - "Pp": "0", - "S": "4", - "T": "15", - "V": "VG", - "W": "1", - "U": "3", - "$": "900" - }, - { - "D": "ENE", - "F": "14", - "G": "9", - "H": "58", - "Pp": "0", - "S": "4", - "T": "14", - "V": "VG", - "W": "1", - "U": "1", - "$": "1080" - }, - { - "D": "SE", - "F": "8", - "G": "18", - "H": "76", - "Pp": "0", - "S": "9", - "T": "10", - "V": "VG", - "W": "0", - "U": "0", - "$": "1260" - } - ] + "time": "2024-11-23T12:00Z", + "screenTemperature": 9.28, + "maxScreenAirTemp": 9.28, + "minScreenAirTemp": 8.14, + "screenDewPointTemperature": 8.54, + "feelsLikeTemperature": 5.75, + "windSpeed10m": 7.87, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 15.43, + "max10mWindGust": 19.04, + "visibility": 5106, + "screenRelativeHumidity": 95.13, + "mslp": 98750, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.53, + "totalPrecipAmount": 0.25, + "totalSnowAmount": 0, + "probOfPrecipitation": 61 }, { - "type": "Day", - "value": "2020-04-26Z", - "Rep": [ - { - "D": "SSE", - "F": "5", - "G": "16", - "H": "84", - "Pp": "0", - "S": "7", - "T": "7", - "V": "VG", - "W": "0", - "U": "0", - "$": "0" - }, - { - "D": "S", - "F": "4", - "G": "16", - "H": "89", - "Pp": "0", - "S": "7", - "T": "6", - "V": "GO", - "W": "0", - "U": "0", - "$": "180" - }, - { - "D": "S", - "F": "4", - "G": "16", - "H": "87", - "Pp": "0", - "S": "7", - "T": "7", - "V": "GO", - "W": "1", - "U": "1", - "$": "360" - }, - { - "D": "SSW", - "F": "11", - "G": "13", - "H": "69", - "Pp": "0", - "S": "9", - "T": "13", - "V": "VG", - "W": "1", - "U": "4", - "$": "540" - }, - { - "D": "SW", - "F": "15", - "G": "18", - "H": "50", - "Pp": "8", - "S": "9", - "T": "17", - "V": "VG", - "W": "1", - "U": "5", - "$": "720" - }, - { - "D": "SW", - "F": "16", - "G": "16", - "H": "47", - "Pp": "8", - "S": "7", - "T": "18", - "V": "VG", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "SW", - "F": "15", - "G": "13", - "H": "56", - "Pp": "3", - "S": "7", - "T": "17", - "V": "VG", - "W": "3", - "U": "1", - "$": "1080" - }, - { - "D": "SW", - "F": "13", - "G": "11", - "H": "76", - "Pp": "4", - "S": "4", - "T": "13", - "V": "VG", - "W": "7", - "U": "0", - "$": "1260" - } - ] + "time": "2024-11-23T13:00Z", + "screenTemperature": 9.93, + "maxScreenAirTemp": 9.93, + "minScreenAirTemp": 9.28, + "screenDewPointTemperature": 8.97, + "feelsLikeTemperature": 6.8, + "windSpeed10m": 7.06, + "windDirectionFrom10m": 178, + "windGustSpeed10m": 15.48, + "max10mWindGust": 18.1, + "visibility": 11368, + "screenRelativeHumidity": 93.78, + "mslp": 98683, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.82, + "totalPrecipAmount": 0.52, + "totalSnowAmount": 0, + "probOfPrecipitation": 65 }, { - "type": "Day", - "value": "2020-04-27Z", - "Rep": [ - { - "D": "SSW", - "F": "10", - "G": "13", - "H": "75", - "Pp": "5", - "S": "7", - "T": "11", - "V": "GO", - "W": "7", - "U": "0", - "$": "0" - }, - { - "D": "W", - "F": "9", - "G": "13", - "H": "84", - "Pp": "9", - "S": "7", - "T": "10", - "V": "GO", - "W": "7", - "U": "0", - "$": "180" - }, - { - "D": "NW", - "F": "7", - "G": "16", - "H": "85", - "Pp": "50", - "S": "9", - "T": "9", - "V": "GO", - "W": "12", - "U": "1", - "$": "360" - }, - { - "D": "NW", - "F": "9", - "G": "11", - "H": "78", - "Pp": "36", - "S": "4", - "T": "10", - "V": "VG", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "WNW", - "F": "11", - "G": "11", - "H": "66", - "Pp": "9", - "S": "4", - "T": "12", - "V": "VG", - "W": "7", - "U": "4", - "$": "720" - }, - { - "D": "W", - "F": "11", - "G": "13", - "H": "62", - "Pp": "9", - "S": "7", - "T": "13", - "V": "VG", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "E", - "F": "11", - "G": "11", - "H": "64", - "Pp": "10", - "S": "7", - "T": "12", - "V": "VG", - "W": "7", - "U": "1", - "$": "1080" - }, - { - "D": "SE", - "F": "9", - "G": "13", - "H": "78", - "Pp": "9", - "S": "7", - "T": "10", - "V": "VG", - "W": "7", - "U": "0", - "$": "1260" - } - ] + "time": "2024-11-23T14:00Z", + "screenTemperature": 11.13, + "maxScreenAirTemp": 11.14, + "minScreenAirTemp": 9.93, + "screenDewPointTemperature": 9.99, + "feelsLikeTemperature": 8.41, + "windSpeed10m": 6.35, + "windDirectionFrom10m": 179, + "windGustSpeed10m": 13.61, + "max10mWindGust": 15.05, + "visibility": 18523, + "screenRelativeHumidity": 92.73, + "mslp": 98634, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 }, { - "type": "Day", - "value": "2020-04-28Z", - "Rep": [ - { - "D": "SE", - "F": "7", - "G": "13", - "H": "85", - "Pp": "9", - "S": "7", - "T": "9", - "V": "VG", - "W": "7", - "U": "0", - "$": "0" - }, - { - "D": "E", - "F": "7", - "G": "9", - "H": "91", - "Pp": "11", - "S": "4", - "T": "8", - "V": "GO", - "W": "7", - "U": "0", - "$": "180" - }, - { - "D": "ESE", - "F": "7", - "G": "9", - "H": "92", - "Pp": "12", - "S": "4", - "T": "8", - "V": "GO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "ESE", - "F": "9", - "G": "13", - "H": "77", - "Pp": "14", - "S": "7", - "T": "11", - "V": "GO", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "ESE", - "F": "12", - "G": "16", - "H": "64", - "Pp": "14", - "S": "7", - "T": "13", - "V": "GO", - "W": "7", - "U": "3", - "$": "720" - }, - { - "D": "ESE", - "F": "12", - "G": "18", - "H": "66", - "Pp": "15", - "S": "9", - "T": "13", - "V": "GO", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "SSE", - "F": "11", - "G": "13", - "H": "73", - "Pp": "15", - "S": "7", - "T": "12", - "V": "GO", - "W": "7", - "U": "1", - "$": "1080" - }, - { - "D": "SE", - "F": "9", - "G": "13", - "H": "81", - "Pp": "13", - "S": "7", - "T": "10", - "V": "GO", - "W": "7", - "U": "0", - "$": "1260" - } - ] + "time": "2024-11-23T15:00Z", + "screenTemperature": 11.98, + "maxScreenAirTemp": 12.03, + "minScreenAirTemp": 11.13, + "screenDewPointTemperature": 10.75, + "feelsLikeTemperature": 9.81, + "windSpeed10m": 5.14, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 11.14, + "max10mWindGust": 13.9, + "visibility": 17498, + "screenRelativeHumidity": 92.28, + "mslp": 98613, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.7, + "totalPrecipAmount": 0.09, + "totalSnowAmount": 0, + "probOfPrecipitation": 37 }, { - "type": "Day", - "value": "2020-04-29Z", - "Rep": [ - { - "D": "SSE", - "F": "7", - "G": "13", - "H": "87", - "Pp": "11", - "S": "7", - "T": "9", - "V": "GO", - "W": "7", - "U": "0", - "$": "0" - }, - { - "D": "SSE", - "F": "7", - "G": "13", - "H": "91", - "Pp": "15", - "S": "7", - "T": "9", - "V": "GO", - "W": "8", - "U": "0", - "$": "180" - }, - { - "D": "ESE", - "F": "7", - "G": "13", - "H": "89", - "Pp": "8", - "S": "7", - "T": "9", - "V": "GO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "SSE", - "F": "10", - "G": "20", - "H": "75", - "Pp": "8", - "S": "11", - "T": "12", - "V": "VG", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "S", - "F": "12", - "G": "22", - "H": "68", - "Pp": "11", - "S": "11", - "T": "14", - "V": "GO", - "W": "7", - "U": "3", - "$": "720" - }, - { - "D": "S", - "F": "12", - "G": "27", - "H": "68", - "Pp": "55", - "S": "13", - "T": "14", - "V": "GO", - "W": "12", - "U": "1", - "$": "900" - }, - { - "D": "SSE", - "F": "11", - "G": "22", - "H": "76", - "Pp": "34", - "S": "11", - "T": "13", - "V": "VG", - "W": "10", - "U": "1", - "$": "1080" - }, - { - "D": "SSE", - "F": "9", - "G": "20", - "H": "86", - "Pp": "20", - "S": "11", - "T": "11", - "V": "VG", - "W": "7", - "U": "0", - "$": "1260" - } - ] + "time": "2024-11-23T16:00Z", + "screenTemperature": 12.56, + "maxScreenAirTemp": 12.59, + "minScreenAirTemp": 11.98, + "screenDewPointTemperature": 11.33, + "feelsLikeTemperature": 10.83, + "windSpeed10m": 4.29, + "windDirectionFrom10m": 197, + "windGustSpeed10m": 9.96, + "max10mWindGust": 10.5, + "visibility": 16335, + "screenRelativeHumidity": 92.27, + "mslp": 98660, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 1.23, + "totalPrecipAmount": 0.27, + "totalSnowAmount": 0, + "probOfPrecipitation": 36 + }, + { + "time": "2024-11-23T17:00Z", + "screenTemperature": 12.95, + "maxScreenAirTemp": 12.99, + "minScreenAirTemp": 12.56, + "screenDewPointTemperature": 11.75, + "feelsLikeTemperature": 11.27, + "windSpeed10m": 4.33, + "windDirectionFrom10m": 203, + "windGustSpeed10m": 9.88, + "max10mWindGust": 10.47, + "visibility": 18682, + "screenRelativeHumidity": 92.39, + "mslp": 98710, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 11 + }, + { + "time": "2024-11-23T18:00Z", + "screenTemperature": 13, + "maxScreenAirTemp": 13.05, + "minScreenAirTemp": 12.9, + "screenDewPointTemperature": 11.56, + "feelsLikeTemperature": 11.32, + "windSpeed10m": 4.31, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 8.67, + "max10mWindGust": 9.95, + "visibility": 19530, + "screenRelativeHumidity": 91, + "mslp": 98710, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2024-11-23T19:00Z", + "screenTemperature": 13.02, + "maxScreenAirTemp": 13.16, + "minScreenAirTemp": 13, + "screenDewPointTemperature": 11.92, + "feelsLikeTemperature": 11.12, + "windSpeed10m": 4.85, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 10.4, + "max10mWindGust": 11.01, + "visibility": 13803, + "screenRelativeHumidity": 93.07, + "mslp": 98682, + "uvIndex": 0, + "significantWeatherCode": 13, + "precipitationRate": 5.45, + "totalPrecipAmount": 0.51, + "totalSnowAmount": 0, + "probOfPrecipitation": 74 + }, + { + "time": "2024-11-23T20:00Z", + "screenTemperature": 13.67, + "maxScreenAirTemp": 13.72, + "minScreenAirTemp": 13.02, + "screenDewPointTemperature": 12.07, + "feelsLikeTemperature": 11.23, + "windSpeed10m": 6.31, + "windDirectionFrom10m": 187, + "windGustSpeed10m": 12.77, + "max10mWindGust": 13.53, + "visibility": 28855, + "screenRelativeHumidity": 90.06, + "mslp": 98692, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 11 + }, + { + "time": "2024-11-23T21:00Z", + "screenTemperature": 14.02, + "maxScreenAirTemp": 14.03, + "minScreenAirTemp": 13.67, + "screenDewPointTemperature": 11.71, + "feelsLikeTemperature": 11.65, + "windSpeed10m": 6.11, + "windDirectionFrom10m": 178, + "windGustSpeed10m": 12.31, + "max10mWindGust": 13.07, + "visibility": 34707, + "screenRelativeHumidity": 86.02, + "mslp": 98682, + "uvIndex": 0, + "significantWeatherCode": 9, + "precipitationRate": 0.35, + "totalPrecipAmount": 0.11, + "totalSnowAmount": 0, + "probOfPrecipitation": 30 + }, + { + "time": "2024-11-23T22:00Z", + "screenTemperature": 13.98, + "maxScreenAirTemp": 14.02, + "minScreenAirTemp": 13.9, + "screenDewPointTemperature": 11.78, + "feelsLikeTemperature": 11.43, + "windSpeed10m": 6.57, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 13.29, + "max10mWindGust": 14.34, + "visibility": 37141, + "screenRelativeHumidity": 86.59, + "mslp": 98631, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, + { + "time": "2024-11-23T23:00Z", + "screenTemperature": 14.28, + "maxScreenAirTemp": 14.29, + "minScreenAirTemp": 13.98, + "screenDewPointTemperature": 12.06, + "feelsLikeTemperature": 11.42, + "windSpeed10m": 7.38, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 14.29, + "max10mWindGust": 15.45, + "visibility": 37580, + "screenRelativeHumidity": 86.56, + "mslp": 98571, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2024-11-24T00:00Z", + "screenTemperature": 14.4, + "maxScreenAirTemp": 14.44, + "minScreenAirTemp": 14.28, + "screenDewPointTemperature": 12.25, + "feelsLikeTemperature": 11.52, + "windSpeed10m": 7.44, + "windDirectionFrom10m": 171, + "windGustSpeed10m": 14.08, + "max10mWindGust": 14.92, + "visibility": 39734, + "screenRelativeHumidity": 86.99, + "mslp": 98492, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2024-11-24T01:00Z", + "screenTemperature": 14.38, + "maxScreenAirTemp": 14.42, + "minScreenAirTemp": 14.35, + "screenDewPointTemperature": 12.25, + "feelsLikeTemperature": 11.62, + "windSpeed10m": 7.16, + "windDirectionFrom10m": 170, + "windGustSpeed10m": 13.92, + "max10mWindGust": 14.5, + "visibility": 39173, + "screenRelativeHumidity": 87.03, + "mslp": 98422, + "uvIndex": 0, + "significantWeatherCode": 9, + "precipitationRate": 1.24, + "totalPrecipAmount": 0.17, + "totalSnowAmount": 0, + "probOfPrecipitation": 40 + }, + { + "time": "2024-11-24T02:00Z", + "screenTemperature": 14.19, + "maxScreenAirTemp": 14.38, + "minScreenAirTemp": 14.16, + "screenDewPointTemperature": 12.49, + "feelsLikeTemperature": 11.33, + "windSpeed10m": 7.47, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 14.46, + "max10mWindGust": 15.43, + "visibility": 31444, + "screenRelativeHumidity": 89.63, + "mslp": 98351, + "uvIndex": 0, + "significantWeatherCode": 13, + "precipitationRate": 2.07, + "totalPrecipAmount": 0.21, + "totalSnowAmount": 0, + "probOfPrecipitation": 74 + }, + { + "time": "2024-11-24T03:00Z", + "screenTemperature": 14.44, + "maxScreenAirTemp": 14.48, + "minScreenAirTemp": 14.19, + "screenDewPointTemperature": 12.35, + "feelsLikeTemperature": 11.65, + "windSpeed10m": 7.25, + "windDirectionFrom10m": 187, + "windGustSpeed10m": 14.32, + "max10mWindGust": 15.51, + "visibility": 20239, + "screenRelativeHumidity": 87.4, + "mslp": 98310, + "uvIndex": 0, + "significantWeatherCode": 13, + "precipitationRate": 2.63, + "totalPrecipAmount": 0.34, + "totalSnowAmount": 0, + "probOfPrecipitation": 73 + }, + { + "time": "2024-11-24T04:00Z", + "screenTemperature": 14.42, + "maxScreenAirTemp": 14.45, + "minScreenAirTemp": 14.37, + "screenDewPointTemperature": 12.28, + "feelsLikeTemperature": 11.68, + "windSpeed10m": 7.09, + "windDirectionFrom10m": 189, + "windGustSpeed10m": 13.8, + "max10mWindGust": 15.24, + "visibility": 24690, + "screenRelativeHumidity": 87.07, + "mslp": 98310, + "uvIndex": 0, + "significantWeatherCode": 9, + "precipitationRate": 1.32, + "totalPrecipAmount": 0.28, + "totalSnowAmount": 0, + "probOfPrecipitation": 50 + }, + { + "time": "2024-11-24T05:00Z", + "screenTemperature": 14.31, + "maxScreenAirTemp": 14.42, + "minScreenAirTemp": 14.11, + "screenDewPointTemperature": 12.17, + "feelsLikeTemperature": 11.79, + "windSpeed10m": 6.58, + "windDirectionFrom10m": 202, + "windGustSpeed10m": 12.7, + "max10mWindGust": 14.06, + "visibility": 25995, + "screenRelativeHumidity": 87.01, + "mslp": 98330, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.65, + "totalPrecipAmount": 0.25, + "totalSnowAmount": 0, + "probOfPrecipitation": 47 + }, + { + "time": "2024-11-24T06:00Z", + "screenTemperature": 13.43, + "maxScreenAirTemp": 14.31, + "minScreenAirTemp": 13.41, + "screenDewPointTemperature": 10.33, + "feelsLikeTemperature": 10.74, + "windSpeed10m": 6.71, + "windDirectionFrom10m": 216, + "windGustSpeed10m": 12.73, + "max10mWindGust": 13.79, + "visibility": 27446, + "screenRelativeHumidity": 81.67, + "mslp": 98396, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 1.04, + "totalPrecipAmount": 0.3, + "totalSnowAmount": 0, + "probOfPrecipitation": 42 + }, + { + "time": "2024-11-24T07:00Z", + "screenTemperature": 12.48, + "maxScreenAirTemp": 13.43, + "minScreenAirTemp": 12.47, + "screenDewPointTemperature": 9.48, + "feelsLikeTemperature": 10.09, + "windSpeed10m": 5.72, + "windDirectionFrom10m": 214, + "windGustSpeed10m": 11.03, + "max10mWindGust": 12.54, + "visibility": 24289, + "screenRelativeHumidity": 81.94, + "mslp": 98458, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 1.17, + "totalPrecipAmount": 0.16, + "totalSnowAmount": 0, + "probOfPrecipitation": 40 + }, + { + "time": "2024-11-24T08:00Z", + "screenTemperature": 11.88, + "maxScreenAirTemp": 12.48, + "minScreenAirTemp": 11.86, + "screenDewPointTemperature": 8.86, + "feelsLikeTemperature": 9.53, + "windSpeed10m": 5.48, + "windDirectionFrom10m": 209, + "windGustSpeed10m": 10.3, + "max10mWindGust": 11.11, + "visibility": 30442, + "screenRelativeHumidity": 81.73, + "mslp": 98548, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.29, + "totalPrecipAmount": 0.08, + "totalSnowAmount": 0, + "probOfPrecipitation": 38 + }, + { + "time": "2024-11-24T09:00Z", + "screenTemperature": 11.46, + "maxScreenAirTemp": 11.88, + "minScreenAirTemp": 11.45, + "screenDewPointTemperature": 8.21, + "feelsLikeTemperature": 9.06, + "windSpeed10m": 5.44, + "windDirectionFrom10m": 201, + "windGustSpeed10m": 9.99, + "max10mWindGust": 10.31, + "visibility": 28370, + "screenRelativeHumidity": 80.35, + "mslp": 98638, + "uvIndex": 1, + "significantWeatherCode": 10, + "precipitationRate": 0.28, + "totalPrecipAmount": 0.04, + "totalSnowAmount": 0, + "probOfPrecipitation": 26 + }, + { + "time": "2024-11-24T10:00Z", + "screenTemperature": 11.54, + "maxScreenAirTemp": 11.56, + "minScreenAirTemp": 11.46, + "screenDewPointTemperature": 7.52, + "feelsLikeTemperature": 9.03, + "windSpeed10m": 5.72, + "windDirectionFrom10m": 199, + "windGustSpeed10m": 10.28, + "max10mWindGust": 10.83, + "visibility": 29181, + "screenRelativeHumidity": 76.29, + "mslp": 98696, + "uvIndex": 1, + "significantWeatherCode": 10, + "precipitationRate": 0.28, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 25 + }, + { + "time": "2024-11-24T11:00Z", + "screenTemperature": 11.66, + "maxScreenAirTemp": 11.67, + "minScreenAirTemp": 11.54, + "screenDewPointTemperature": 7.29, + "feelsLikeTemperature": 9.17, + "windSpeed10m": 5.68, + "windDirectionFrom10m": 199, + "windGustSpeed10m": 10.06, + "max10mWindGust": 11.06, + "visibility": 33278, + "screenRelativeHumidity": 74.39, + "mslp": 98755, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2024-11-24T12:00Z", + "screenTemperature": 11.82, + "maxScreenAirTemp": 11.84, + "minScreenAirTemp": 11.66, + "screenDewPointTemperature": 6.61, + "feelsLikeTemperature": 8.98, + "windSpeed10m": 6.65, + "windDirectionFrom10m": 203, + "windGustSpeed10m": 11.85, + "max10mWindGust": 12.49, + "visibility": 36358, + "screenRelativeHumidity": 70.26, + "mslp": 98748, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-24T13:00Z", + "screenTemperature": 11.84, + "maxScreenAirTemp": 11.87, + "minScreenAirTemp": 11.82, + "screenDewPointTemperature": 6.06, + "feelsLikeTemperature": 8.85, + "windSpeed10m": 7.07, + "windDirectionFrom10m": 203, + "windGustSpeed10m": 12.6, + "max10mWindGust": 14.16, + "visibility": 38017, + "screenRelativeHumidity": 67.6, + "mslp": 98757, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-24T14:00Z", + "screenTemperature": 11.73, + "maxScreenAirTemp": 11.84, + "minScreenAirTemp": 11.72, + "screenDewPointTemperature": 5.74, + "feelsLikeTemperature": 8.64, + "windSpeed10m": 7.33, + "windDirectionFrom10m": 201, + "windGustSpeed10m": 13.04, + "max10mWindGust": 14.33, + "visibility": 36175, + "screenRelativeHumidity": 66.62, + "mslp": 98737, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-24T15:00Z", + "screenTemperature": 11.61, + "maxScreenAirTemp": 11.73, + "minScreenAirTemp": 11.57, + "screenDewPointTemperature": 5.89, + "feelsLikeTemperature": 8.53, + "windSpeed10m": 7.32, + "windDirectionFrom10m": 198, + "windGustSpeed10m": 13.02, + "max10mWindGust": 15, + "visibility": 35510, + "screenRelativeHumidity": 67.73, + "mslp": 98727, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 4 + }, + { + "time": "2024-11-24T16:00Z", + "screenTemperature": 11.25, + "maxScreenAirTemp": 11.61, + "minScreenAirTemp": 11.24, + "screenDewPointTemperature": 5.8, + "feelsLikeTemperature": 8.25, + "windSpeed10m": 7.05, + "windDirectionFrom10m": 196, + "windGustSpeed10m": 12.84, + "max10mWindGust": 14.78, + "visibility": 34357, + "screenRelativeHumidity": 68.9, + "mslp": 98708, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-24T17:00Z", + "screenTemperature": 11.03, + "maxScreenAirTemp": 11.25, + "minScreenAirTemp": 11.02, + "screenDewPointTemperature": 5.9, + "feelsLikeTemperature": 8.03, + "windSpeed10m": 7.04, + "windDirectionFrom10m": 194, + "windGustSpeed10m": 12.69, + "max10mWindGust": 14.44, + "visibility": 37801, + "screenRelativeHumidity": 70.45, + "mslp": 98689, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 4 + }, + { + "time": "2024-11-24T18:00Z", + "screenTemperature": 10.86, + "maxScreenAirTemp": 11.03, + "minScreenAirTemp": 10.8, + "screenDewPointTemperature": 5.96, + "feelsLikeTemperature": 7.85, + "windSpeed10m": 7.04, + "windDirectionFrom10m": 196, + "windGustSpeed10m": 12.82, + "max10mWindGust": 14.25, + "visibility": 39237, + "screenRelativeHumidity": 71.58, + "mslp": 98670, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-24T19:00Z", + "screenTemperature": 10.79, + "maxScreenAirTemp": 10.86, + "minScreenAirTemp": 10.75, + "screenDewPointTemperature": 5.92, + "feelsLikeTemperature": 7.81, + "windSpeed10m": 6.93, + "windDirectionFrom10m": 196, + "windGustSpeed10m": 12.62, + "max10mWindGust": 13.94, + "visibility": 40795, + "screenRelativeHumidity": 71.71, + "mslp": 98669, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-24T20:00Z", + "screenTemperature": 10.65, + "maxScreenAirTemp": 10.79, + "minScreenAirTemp": 10.62, + "screenDewPointTemperature": 5.78, + "feelsLikeTemperature": 7.7, + "windSpeed10m": 6.82, + "windDirectionFrom10m": 202, + "windGustSpeed10m": 12.52, + "max10mWindGust": 13.63, + "visibility": 41929, + "screenRelativeHumidity": 71.7, + "mslp": 98678, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-24T21:00Z", + "screenTemperature": 10.53, + "maxScreenAirTemp": 10.65, + "minScreenAirTemp": 10.5, + "screenDewPointTemperature": 5.84, + "feelsLikeTemperature": 7.48, + "windSpeed10m": 7.08, + "windDirectionFrom10m": 203, + "windGustSpeed10m": 12.89, + "max10mWindGust": 13.18, + "visibility": 44628, + "screenRelativeHumidity": 72.53, + "mslp": 98677, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-24T22:00Z", + "screenTemperature": 10.47, + "maxScreenAirTemp": 10.53, + "minScreenAirTemp": 10.42, + "screenDewPointTemperature": 5.65, + "feelsLikeTemperature": 7.32, + "windSpeed10m": 7.41, + "windDirectionFrom10m": 204, + "windGustSpeed10m": 13.4, + "max10mWindGust": 13.81, + "visibility": 47105, + "screenRelativeHumidity": 71.84, + "mslp": 98704, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 4 + }, + { + "time": "2024-11-24T23:00Z", + "screenTemperature": 10.32, + "maxScreenAirTemp": 10.47, + "minScreenAirTemp": 10.26, + "screenDewPointTemperature": 5.54, + "feelsLikeTemperature": 7.08, + "windSpeed10m": 7.7, + "windDirectionFrom10m": 207, + "windGustSpeed10m": 14.01, + "max10mWindGust": 14.01, + "visibility": 52166, + "screenRelativeHumidity": 72.03, + "mslp": 98704, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-25T00:00Z", + "screenTemperature": 10.22, + "maxScreenAirTemp": 10.32, + "minScreenAirTemp": 10.06, + "screenDewPointTemperature": 5.64, + "feelsLikeTemperature": 7.09, + "windSpeed10m": 7.33, + "windDirectionFrom10m": 211, + "windGustSpeed10m": 13.11, + "max10mWindGust": 13.65, + "visibility": 51563, + "screenRelativeHumidity": 72.97, + "mslp": 98712, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 23 + }, + { + "time": "2024-11-25T01:00Z", + "screenTemperature": 9.98, + "maxScreenAirTemp": 10.22, + "minScreenAirTemp": 9.94, + "screenDewPointTemperature": 5.98, + "feelsLikeTemperature": 6.88, + "windSpeed10m": 7.04, + "windDirectionFrom10m": 215, + "windGustSpeed10m": 12.51, + "max10mWindGust": 12.51, + "visibility": 52180, + "screenRelativeHumidity": 76.02, + "mslp": 98741, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 11 + }, + { + "time": "2024-11-25T02:00Z", + "screenTemperature": 9.59, + "maxScreenAirTemp": 9.98, + "minScreenAirTemp": 9.53, + "screenDewPointTemperature": 5.22, + "feelsLikeTemperature": 6.37, + "windSpeed10m": 7.14, + "windDirectionFrom10m": 222, + "windGustSpeed10m": 13.02, + "max10mWindGust": 13.02, + "visibility": 41536, + "screenRelativeHumidity": 74.07, + "mslp": 98788, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2024-11-25T03:00Z", + "screenTemperature": 9.27, + "maxScreenAirTemp": 9.59, + "minScreenAirTemp": 9.25, + "screenDewPointTemperature": 5.16, + "feelsLikeTemperature": 6.06, + "windSpeed10m": 6.91, + "windDirectionFrom10m": 226, + "windGustSpeed10m": 12.42, + "max10mWindGust": 12.88, + "visibility": 38854, + "screenRelativeHumidity": 75.45, + "mslp": 98816, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-25T04:00Z", + "screenTemperature": 9.09, + "maxScreenAirTemp": 9.27, + "minScreenAirTemp": 9.04, + "screenDewPointTemperature": 4.8, + "feelsLikeTemperature": 5.8, + "windSpeed10m": 7.04, + "windDirectionFrom10m": 228, + "windGustSpeed10m": 12.56, + "max10mWindGust": 12.8, + "visibility": 36196, + "screenRelativeHumidity": 74.38, + "mslp": 98858, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2024-11-25T05:00Z", + "screenTemperature": 8.82, + "maxScreenAirTemp": 9.09, + "minScreenAirTemp": 8.81, + "screenDewPointTemperature": 4.54, + "feelsLikeTemperature": 5.36, + "windSpeed10m": 7.26, + "windDirectionFrom10m": 232, + "windGustSpeed10m": 13.12, + "max10mWindGust": 14.39, + "visibility": 42056, + "screenRelativeHumidity": 74.58, + "mslp": 98910, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, + { + "time": "2024-11-25T06:00Z", + "screenTemperature": 8.66, + "maxScreenAirTemp": 8.88, + "minScreenAirTemp": 8.63, + "screenDewPointTemperature": 4.28, + "feelsLikeTemperature": 5.14, + "windSpeed10m": 7.32, + "windDirectionFrom10m": 235, + "windGustSpeed10m": 13.39, + "max10mWindGust": 15.94, + "visibility": 41207, + "screenRelativeHumidity": 74.14, + "mslp": 98961, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, + { + "time": "2024-11-25T07:00Z", + "screenTemperature": 8.58, + "maxScreenAirTemp": 8.69, + "minScreenAirTemp": 8.56, + "screenDewPointTemperature": 4.21, + "feelsLikeTemperature": 5.01, + "windSpeed10m": 7.44, + "windDirectionFrom10m": 240, + "windGustSpeed10m": 13.28, + "max10mWindGust": 14.8, + "visibility": 38861, + "screenRelativeHumidity": 74.26, + "mslp": 99061, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, + { + "time": "2024-11-25T08:00Z", + "screenTemperature": 8.42, + "maxScreenAirTemp": 8.58, + "minScreenAirTemp": 8.42, + "screenDewPointTemperature": 3.99, + "feelsLikeTemperature": 4.84, + "windSpeed10m": 7.46, + "windDirectionFrom10m": 243, + "windGustSpeed10m": 13.21, + "max10mWindGust": 14.59, + "visibility": 36897, + "screenRelativeHumidity": 73.86, + "mslp": 99161, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, + { + "time": "2024-11-25T09:00Z", + "screenTemperature": 8.4, + "maxScreenAirTemp": 8.42, + "minScreenAirTemp": 8.27, + "screenDewPointTemperature": 3.83, + "feelsLikeTemperature": 4.77, + "windSpeed10m": 7.59, + "windDirectionFrom10m": 243, + "windGustSpeed10m": 13.29, + "max10mWindGust": 13.29, + "visibility": 36152, + "screenRelativeHumidity": 73.17, + "mslp": 99252, + "uvIndex": 1, + "significantWeatherCode": 1, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, + { + "time": "2024-11-25T10:00Z", + "screenTemperature": 8.66, + "maxScreenAirTemp": 8.66, + "minScreenAirTemp": 8.4, + "screenDewPointTemperature": 3.94, + "feelsLikeTemperature": 4.96, + "windSpeed10m": 8, + "windDirectionFrom10m": 245, + "windGustSpeed10m": 13.83, + "max10mWindGust": 13.83, + "visibility": 36320, + "screenRelativeHumidity": 72.24, + "mslp": 99342, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, + { + "time": "2024-11-25T11:00Z", + "screenTemperature": 8.83, + "maxScreenAirTemp": 8.83, + "minScreenAirTemp": 8.66, + "screenDewPointTemperature": 3.7, + "feelsLikeTemperature": 5.05, + "windSpeed10m": 8.44, + "windDirectionFrom10m": 249, + "windGustSpeed10m": 14.47, + "max10mWindGust": 14.47, + "visibility": 32194, + "screenRelativeHumidity": 69.92, + "mslp": 99424, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 3 + }, + { + "time": "2024-11-25T12:00Z", + "screenTemperature": 8.94, + "screenDewPointTemperature": 3.65, + "feelsLikeTemperature": 5.18, + "windSpeed10m": 8.52, + "windDirectionFrom10m": 251, + "windGustSpeed10m": 14.49, + "visibility": 32255, + "screenRelativeHumidity": 68.89, + "mslp": 99488, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0, + "probOfPrecipitation": 2 } ] } } - } + ], + "parameters": [ + { + "totalSnowAmount": { + "type": "Parameter", + "description": "Total Snow Amount Over Previous Hour", + "unit": { + "label": "millimetres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm" + } + } + }, + "screenTemperature": { + "type": "Parameter", + "description": "Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "visibility": { + "type": "Parameter", + "description": "Visibility", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "windDirectionFrom10m": { + "type": "Parameter", + "description": "10m Wind From Direction", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "precipitationRate": { + "type": "Parameter", + "description": "Precipitation Rate", + "unit": { + "label": "millimetres per hour", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm/h" + } + } + }, + "maxScreenAirTemp": { + "type": "Parameter", + "description": "Maximum Screen Air Temperature Over Previous Hour", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "feelsLikeTemperature": { + "type": "Parameter", + "description": "Feels Like Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "screenDewPointTemperature": { + "type": "Parameter", + "description": "Screen Dew Point Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "screenRelativeHumidity": { + "type": "Parameter", + "description": "Screen Relative Humidity", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "windSpeed10m": { + "type": "Parameter", + "description": "10m Wind Speed", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "probOfPrecipitation": { + "type": "Parameter", + "description": "Probability of Precipitation", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "max10mWindGust": { + "type": "Parameter", + "description": "Maximum 10m Wind Gust Speed Over Previous Hour", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "significantWeatherCode": { + "type": "Parameter", + "description": "Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://datahub.metoffice.gov.uk/", + "type": "1" + } + } + }, + "minScreenAirTemp": { + "type": "Parameter", + "description": "Minimum Screen Air Temperature Over Previous Hour", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "totalPrecipAmount": { + "type": "Parameter", + "description": "Total Precipitation Amount Over Previous Hour", + "unit": { + "label": "millimetres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm" + } + } + }, + "mslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "windGustSpeed10m": { + "type": "Parameter", + "description": "10m Wind Gust Speed", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "uvIndex": { + "type": "Parameter", + "description": "UV Index", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "1" + } + } + } + } + ] }, "kingslynn_daily": { - "SiteRep": { - "Wx": { - "Param": [ - { - "name": "FDm", - "units": "C", - "$": "Feels Like Day Maximum Temperature" + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0.40190000000000003, 52.7561, 5] + }, + "properties": { + "location": { + "name": "King's Lynn" }, - { - "name": "FNm", - "units": "C", - "$": "Feels Like Night Minimum Temperature" - }, - { - "name": "Dm", - "units": "C", - "$": "Day Maximum Temperature" - }, - { - "name": "Nm", - "units": "C", - "$": "Night Minimum Temperature" - }, - { - "name": "Gn", - "units": "mph", - "$": "Wind Gust Noon" - }, - { - "name": "Gm", - "units": "mph", - "$": "Wind Gust Midnight" - }, - { - "name": "Hn", - "units": "%", - "$": "Screen Relative Humidity Noon" - }, - { - "name": "Hm", - "units": "%", - "$": "Screen Relative Humidity Midnight" - }, - { - "name": "V", - "units": "", - "$": "Visibility" - }, - { - "name": "D", - "units": "compass", - "$": "Wind Direction" - }, - { - "name": "S", - "units": "mph", - "$": "Wind Speed" - }, - { - "name": "U", - "units": "", - "$": "Max UV Index" - }, - { - "name": "W", - "units": "", - "$": "Weather Type" - }, - { - "name": "PPd", - "units": "%", - "$": "Precipitation Probability Day" - }, - { - "name": "PPn", - "units": "%", - "$": "Precipitation Probability Night" - } - ] - }, - "DV": { - "dataDate": "2020-04-25T08:00:00Z", - "type": "Forecast", - "Location": { - "i": "322380", - "lat": "52.7561", - "lon": "0.4019", - "name": "KING'S LYNN", - "country": "ENGLAND", - "continent": "EUROPE", - "elevation": "5.0", - "Period": [ + "requestPointDistance": 2720.9208, + "modelRunDate": "2024-11-23T12:00Z", + "timeSeries": [ { - "type": "Day", - "value": "2020-04-25Z", - "Rep": [ - { - "D": "ESE", - "Gn": "4", - "Hn": "75", - "PPd": "9", - "S": "4", - "V": "VG", - "Dm": "9", - "FDm": "8", - "W": "8", - "U": "3", - "$": "Day" - }, - { - "D": "SSE", - "Gm": "16", - "Hm": "84", - "PPn": "0", - "S": "7", - "V": "VG", - "Nm": "7", - "FNm": "5", - "W": "0", - "$": "Night" - } - ] + "time": "2024-11-22T00:00Z", + "midday10MWindSpeed": 6.74, + "midnight10MWindSpeed": 2.98, + "midday10MWindDirection": 288, + "midnight10MWindDirection": 188, + "midday10MWindGust": 11.32, + "midnight10MWindGust": 7.72, + "middayVisibility": 25304, + "midnightVisibility": 16924, + "middayRelativeHumidity": 68.93, + "midnightRelativeHumidity": 94.01, + "middayMslp": 100530, + "midnightMslp": 101290, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 5.24, + "nightMinScreenTemperature": -0.4, + "dayUpperBoundMaxTemp": 6.17, + "nightUpperBoundMinTemp": 1.91, + "dayLowerBoundMaxTemp": 4.13, + "nightLowerBoundMinTemp": -1.1, + "nightMinFeelsLikeTemp": -4.12, + "dayUpperBoundMaxFeelsLikeTemp": 2.08, + "nightUpperBoundMinFeelsLikeTemp": -1.75, + "dayLowerBoundMaxFeelsLikeTemp": 0.48, + "nightLowerBoundMinFeelsLikeTemp": -4.12, + "nightProbabilityOfPrecipitation": 89, + "nightProbabilityOfSnow": 6, + "nightProbabilityOfHeavySnow": 2, + "nightProbabilityOfRain": 86, + "nightProbabilityOfHeavyRain": 84, + "nightProbabilityOfHail": 18, + "nightProbabilityOfSferics": 9 }, { - "type": "Day", - "value": "2020-04-26Z", - "Rep": [ - { - "D": "SSW", - "Gn": "13", - "Hn": "69", - "PPd": "0", - "S": "9", - "V": "VG", - "Dm": "13", - "FDm": "11", - "W": "1", - "U": "4", - "$": "Day" - }, - { - "D": "SSW", - "Gm": "13", - "Hm": "75", - "PPn": "5", - "S": "7", - "V": "GO", - "Nm": "11", - "FNm": "10", - "W": "7", - "$": "Night" - } - ] + "time": "2024-11-23T00:00Z", + "midday10MWindSpeed": 9.93, + "midnight10MWindSpeed": 8.72, + "midday10MWindDirection": 180, + "midnight10MWindDirection": 199, + "midday10MWindGust": 18, + "midnight10MWindGust": 16.6, + "middayVisibility": 7478, + "midnightVisibility": 42290, + "middayRelativeHumidity": 97.5, + "midnightRelativeHumidity": 90.27, + "middayMslp": 99820, + "midnightMslp": 99340, + "maxUvIndex": 1, + "daySignificantWeatherCode": 15, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 10.16, + "nightMinScreenTemperature": 9.3, + "dayUpperBoundMaxTemp": 13, + "nightUpperBoundMinTemp": 13.01, + "dayLowerBoundMaxTemp": 9.51, + "nightLowerBoundMinTemp": 9.3, + "dayMaxFeelsLikeTemp": 5.14, + "nightMinFeelsLikeTemp": 6.38, + "dayUpperBoundMaxFeelsLikeTemp": 9.42, + "nightUpperBoundMinFeelsLikeTemp": 9.42, + "dayLowerBoundMaxFeelsLikeTemp": 5.14, + "nightLowerBoundMinFeelsLikeTemp": 6.38, + "dayProbabilityOfPrecipitation": 97, + "nightProbabilityOfPrecipitation": 95, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 97, + "nightProbabilityOfRain": 95, + "dayProbabilityOfHeavyRain": 96, + "nightProbabilityOfHeavyRain": 93, + "dayProbabilityOfHail": 19, + "nightProbabilityOfHail": 19, + "dayProbabilityOfSferics": 10, + "nightProbabilityOfSferics": 11 }, { - "type": "Day", - "value": "2020-04-27Z", - "Rep": [ - { - "D": "NW", - "Gn": "11", - "Hn": "78", - "PPd": "36", - "S": "4", - "V": "VG", - "Dm": "10", - "FDm": "9", - "W": "7", - "U": "3", - "$": "Day" - }, - { - "D": "SE", - "Gm": "13", - "Hm": "85", - "PPn": "9", - "S": "7", - "V": "VG", - "Nm": "9", - "FNm": "7", - "W": "7", - "$": "Night" - } - ] + "time": "2024-11-24T00:00Z", + "midday10MWindSpeed": 10.03, + "midnight10MWindSpeed": 6.3, + "midday10MWindDirection": 200, + "midnight10MWindDirection": 214, + "midday10MWindGust": 19, + "midnight10MWindGust": 12.27, + "middayVisibility": 19911, + "midnightVisibility": 44678, + "middayRelativeHumidity": 82.47, + "midnightRelativeHumidity": 84.49, + "middayMslp": 99220, + "midnightMslp": 99277, + "maxUvIndex": 1, + "daySignificantWeatherCode": 12, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 15.66, + "nightMinScreenTemperature": 9.75, + "dayUpperBoundMaxTemp": 16.88, + "nightUpperBoundMinTemp": 10.72, + "dayLowerBoundMaxTemp": 13.97, + "nightLowerBoundMinTemp": 8.25, + "dayMaxFeelsLikeTemp": 11.45, + "nightMinFeelsLikeTemp": 7.13, + "dayUpperBoundMaxFeelsLikeTemp": 12.2, + "nightUpperBoundMinFeelsLikeTemp": 8, + "dayLowerBoundMaxFeelsLikeTemp": 10.46, + "nightLowerBoundMinFeelsLikeTemp": 5.07, + "dayProbabilityOfPrecipitation": 81, + "nightProbabilityOfPrecipitation": 86, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 81, + "nightProbabilityOfRain": 86, + "dayProbabilityOfHeavyRain": 78, + "nightProbabilityOfHeavyRain": 82, + "dayProbabilityOfHail": 15, + "nightProbabilityOfHail": 16, + "dayProbabilityOfSferics": 8, + "nightProbabilityOfSferics": 8 }, { - "type": "Day", - "value": "2020-04-28Z", - "Rep": [ - { - "D": "ESE", - "Gn": "13", - "Hn": "77", - "PPd": "14", - "S": "7", - "V": "GO", - "Dm": "11", - "FDm": "9", - "W": "7", - "U": "3", - "$": "Day" - }, - { - "D": "SSE", - "Gm": "13", - "Hm": "87", - "PPn": "11", - "S": "7", - "V": "GO", - "Nm": "9", - "FNm": "7", - "W": "7", - "$": "Night" - } - ] + "time": "2024-11-25T00:00Z", + "midday10MWindSpeed": 6.91, + "midnight10MWindSpeed": 5.14, + "midday10MWindDirection": 233, + "midnight10MWindDirection": 228, + "midday10MWindGust": 12.61, + "midnight10MWindGust": 9.33, + "middayVisibility": 38960, + "midnightVisibility": 39029, + "middayRelativeHumidity": 70.02, + "midnightRelativeHumidity": 84, + "middayMslp": 99715, + "midnightMslp": 100666, + "maxUvIndex": 1, + "daySignificantWeatherCode": 1, + "nightSignificantWeatherCode": 0, + "dayMaxScreenTemperature": 10.94, + "nightMinScreenTemperature": 4.7, + "dayUpperBoundMaxTemp": 11.7, + "nightUpperBoundMinTemp": 7.14, + "dayLowerBoundMaxTemp": 9.36, + "nightLowerBoundMinTemp": 2.09, + "dayMaxFeelsLikeTemp": 7.72, + "nightMinFeelsLikeTemp": 1.4, + "dayUpperBoundMaxFeelsLikeTemp": 8.79, + "nightUpperBoundMinFeelsLikeTemp": 3.27, + "dayLowerBoundMaxFeelsLikeTemp": 6.22, + "nightLowerBoundMinFeelsLikeTemp": -0.99, + "dayProbabilityOfPrecipitation": 3, + "nightProbabilityOfPrecipitation": 4, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 3, + "nightProbabilityOfRain": 4, + "dayProbabilityOfHeavyRain": 1, + "nightProbabilityOfHeavyRain": 2, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 0 }, { - "type": "Day", - "value": "2020-04-29Z", - "Rep": [ - { - "D": "SSE", - "Gn": "20", - "Hn": "75", - "PPd": "8", - "S": "11", - "V": "VG", - "Dm": "12", - "FDm": "10", - "W": "7", - "U": "3", - "$": "Day" - }, - { - "D": "SSE", - "Gm": "20", - "Hm": "86", - "PPn": "20", - "S": "11", - "V": "VG", - "Nm": "9", - "FNm": "7", - "W": "7", - "$": "Night" - } - ] + "time": "2024-11-26T00:00Z", + "midday10MWindSpeed": 4.33, + "midnight10MWindSpeed": 2.83, + "midday10MWindDirection": 241, + "midnight10MWindDirection": 179, + "midday10MWindGust": 8.23, + "midnight10MWindGust": 4.92, + "middayVisibility": 40528, + "midnightVisibility": 14079, + "middayRelativeHumidity": 77.2, + "midnightRelativeHumidity": 94.47, + "middayMslp": 101355, + "midnightMslp": 101517, + "maxUvIndex": 1, + "daySignificantWeatherCode": 1, + "nightSignificantWeatherCode": 9, + "dayMaxScreenTemperature": 7.93, + "nightMinScreenTemperature": 2.68, + "dayUpperBoundMaxTemp": 10.02, + "nightUpperBoundMinTemp": 9.62, + "dayLowerBoundMaxTemp": 6.28, + "nightLowerBoundMinTemp": -1.11, + "dayMaxFeelsLikeTemp": 5.22, + "nightMinFeelsLikeTemp": 1.74, + "dayUpperBoundMaxFeelsLikeTemp": 7.33, + "nightUpperBoundMinFeelsLikeTemp": 5.97, + "dayLowerBoundMaxFeelsLikeTemp": 4.13, + "nightLowerBoundMinFeelsLikeTemp": -3.64, + "dayProbabilityOfPrecipitation": 3, + "nightProbabilityOfPrecipitation": 52, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 3, + "nightProbabilityOfRain": 52, + "dayProbabilityOfHeavyRain": 2, + "nightProbabilityOfHeavyRain": 48, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 10, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 9 + }, + { + "time": "2024-11-27T00:00Z", + "midday10MWindSpeed": 7.99, + "midnight10MWindSpeed": 5.7, + "midday10MWindDirection": 280, + "midnight10MWindDirection": 304, + "midday10MWindGust": 14.53, + "midnight10MWindGust": 9.97, + "middayVisibility": 12470, + "midnightVisibility": 31017, + "middayRelativeHumidity": 89.2, + "midnightRelativeHumidity": 86.45, + "middayMslp": 100836, + "midnightMslp": 101855, + "maxUvIndex": 1, + "daySignificantWeatherCode": 10, + "nightSignificantWeatherCode": 0, + "dayMaxScreenTemperature": 8.41, + "nightMinScreenTemperature": 4.04, + "dayUpperBoundMaxTemp": 12.97, + "nightUpperBoundMinTemp": 8.08, + "dayLowerBoundMaxTemp": 4.19, + "nightLowerBoundMinTemp": -1.57, + "dayMaxFeelsLikeTemp": 4.11, + "nightMinFeelsLikeTemp": 1.3, + "dayUpperBoundMaxFeelsLikeTemp": 10.56, + "nightUpperBoundMinFeelsLikeTemp": 5.08, + "dayLowerBoundMaxFeelsLikeTemp": 1.68, + "nightLowerBoundMinFeelsLikeTemp": -4.13, + "dayProbabilityOfPrecipitation": 49, + "nightProbabilityOfPrecipitation": 37, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 49, + "nightProbabilityOfRain": 37, + "dayProbabilityOfHeavyRain": 45, + "nightProbabilityOfHeavyRain": 24, + "dayProbabilityOfHail": 9, + "nightProbabilityOfHail": 2, + "dayProbabilityOfSferics": 9, + "nightProbabilityOfSferics": 4 + }, + { + "time": "2024-11-28T00:00Z", + "midday10MWindSpeed": 3.52, + "midnight10MWindSpeed": 3.01, + "midday10MWindDirection": 314, + "midnight10MWindDirection": 98, + "midday10MWindGust": 6.7, + "midnight10MWindGust": 5.08, + "middayVisibility": 38659, + "midnightVisibility": 12067, + "middayRelativeHumidity": 80.63, + "midnightRelativeHumidity": 92.04, + "middayMslp": 102495, + "midnightMslp": 102655, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 7.26, + "nightMinScreenTemperature": 2.84, + "dayUpperBoundMaxTemp": 10.28, + "nightUpperBoundMinTemp": 7.53, + "dayLowerBoundMaxTemp": 4.63, + "nightLowerBoundMinTemp": -1.27, + "dayMaxFeelsLikeTemp": 5.08, + "nightMinFeelsLikeTemp": 1.66, + "dayUpperBoundMaxFeelsLikeTemp": 7.29, + "nightUpperBoundMinFeelsLikeTemp": 4.94, + "dayLowerBoundMaxFeelsLikeTemp": 1.7, + "nightLowerBoundMinFeelsLikeTemp": -3.19, + "dayProbabilityOfPrecipitation": 7, + "nightProbabilityOfPrecipitation": 8, + "dayProbabilityOfSnow": 1, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 7, + "nightProbabilityOfRain": 7, + "dayProbabilityOfHeavyRain": 2, + "nightProbabilityOfHeavyRain": 2, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 0 + }, + { + "time": "2024-11-29T00:00Z", + "midday10MWindSpeed": 4.61, + "midnight10MWindSpeed": 4.68, + "midday10MWindDirection": 143, + "midnight10MWindDirection": 160, + "midday10MWindGust": 8.48, + "midnight10MWindGust": 8.27, + "middayVisibility": 28001, + "midnightVisibility": 32845, + "middayRelativeHumidity": 83.1, + "midnightRelativeHumidity": 90.51, + "middayMslp": 102395, + "midnightMslp": 102078, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 8, + "dayMaxScreenTemperature": 8.34, + "nightMinScreenTemperature": 5.65, + "dayUpperBoundMaxTemp": 13.38, + "nightUpperBoundMinTemp": 11.7, + "dayLowerBoundMaxTemp": 4.49, + "nightLowerBoundMinTemp": -1.92, + "dayMaxFeelsLikeTemp": 5.77, + "nightMinFeelsLikeTemp": 3.8, + "dayUpperBoundMaxFeelsLikeTemp": 11.34, + "nightUpperBoundMinFeelsLikeTemp": 9.44, + "dayLowerBoundMaxFeelsLikeTemp": 2.35, + "nightLowerBoundMinFeelsLikeTemp": -4.87, + "dayProbabilityOfPrecipitation": 8, + "nightProbabilityOfPrecipitation": 12, + "dayProbabilityOfSnow": 1, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 8, + "nightProbabilityOfRain": 12, + "dayProbabilityOfHeavyRain": 3, + "nightProbabilityOfHeavyRain": 2, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 0 } ] } } - } + ], + "parameters": [ + { + "daySignificantWeatherCode": { + "type": "Parameter", + "description": "Day Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://datahub.metoffice.gov.uk/", + "type": "1" + } + } + }, + "midnightRelativeHumidity": { + "type": "Parameter", + "description": "Relative Humidity at Local Midnight", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightProbabilityOfHeavyRain": { + "type": "Parameter", + "description": "Probability of Heavy Rain During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "midnight10MWindSpeed": { + "type": "Parameter", + "description": "10m Wind Speed at Local Midnight", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "nightUpperBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Night Minimum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightUpperBoundMinTemp": { + "type": "Parameter", + "description": "Upper Bound on Night Minimum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "midnightVisibility": { + "type": "Parameter", + "description": "Visibility at Local Midnight", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "dayUpperBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Day Maximum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfRain": { + "type": "Parameter", + "description": "Probability of Rain During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "midday10MWindDirection": { + "type": "Parameter", + "description": "10m Wind Direction at Local Midday", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "nightLowerBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Night Minimum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfHail": { + "type": "Parameter", + "description": "Probability of Hail During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "middayMslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure at Local Midday", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "dayProbabilityOfHeavySnow": { + "type": "Parameter", + "description": "Probability of Heavy Snow During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightProbabilityOfPrecipitation": { + "type": "Parameter", + "description": "Probability of Precipitation During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayProbabilityOfHail": { + "type": "Parameter", + "description": "Probability of Hail During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayProbabilityOfRain": { + "type": "Parameter", + "description": "Probability of Rain During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "midday10MWindSpeed": { + "type": "Parameter", + "description": "10m Wind Speed at Local Midday", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "midday10MWindGust": { + "type": "Parameter", + "description": "10m Wind Gust Speed at Local Midday", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "middayVisibility": { + "type": "Parameter", + "description": "Visibility at Local Midday", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "midnight10MWindGust": { + "type": "Parameter", + "description": "10m Wind Gust Speed at Local Midnight", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "midnightMslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure at Local Midnight", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "dayProbabilityOfSferics": { + "type": "Parameter", + "description": "Probability of Sferics During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightSignificantWeatherCode": { + "type": "Parameter", + "description": "Night Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://datahub.metoffice.gov.uk/", + "type": "1" + } + } + }, + "dayProbabilityOfPrecipitation": { + "type": "Parameter", + "description": "Probability of Precipitation During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayProbabilityOfHeavyRain": { + "type": "Parameter", + "description": "Probability of Heavy Rain During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayMaxScreenTemperature": { + "type": "Parameter", + "description": "Day Maximum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightMinScreenTemperature": { + "type": "Parameter", + "description": "Night Minimum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "midnight10MWindDirection": { + "type": "Parameter", + "description": "10m Wind Direction at Local Midnight", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "maxUvIndex": { + "type": "Parameter", + "description": "Day Maximum UV Index", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "1" + } + } + }, + "dayProbabilityOfSnow": { + "type": "Parameter", + "description": "Probability of Snow During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightProbabilityOfSnow": { + "type": "Parameter", + "description": "Probability of Snow During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayLowerBoundMaxTemp": { + "type": "Parameter", + "description": "Lower Bound on Day Maximum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfHeavySnow": { + "type": "Parameter", + "description": "Probability of Heavy Snow During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayLowerBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Day Maximum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "dayUpperBoundMaxTemp": { + "type": "Parameter", + "description": "Upper Bound on Day Maximum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "dayMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Day Maximum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "middayRelativeHumidity": { + "type": "Parameter", + "description": "Relative Humidity at Local Midday", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightLowerBoundMinTemp": { + "type": "Parameter", + "description": "Lower Bound on Night Minimum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Night Minimum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfSferics": { + "type": "Parameter", + "description": "Probability of Sferics During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + } + } + ] + }, + "kingslynn_hourly": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0.40190000000000003, 52.7561, 5] + }, + "properties": { + "location": { + "name": "King's Lynn" + }, + "requestPointDistance": 2720.9208, + "modelRunDate": "2024-11-23T12:00Z", + "timeSeries": [ + { + "time": "2024-11-23T12:00Z", + "screenTemperature": 7.87, + "maxScreenAirTemp": 7.87, + "minScreenAirTemp": 7.48, + "screenDewPointTemperature": 7.51, + "feelsLikeTemperature": 3.39, + "windSpeed10m": 9.93, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 18, + "max10mWindGust": 18.11, + "visibility": 7478, + "screenRelativeHumidity": 97.5, + "mslp": 99820, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.75, + "totalPrecipAmount": 0.84, + "totalSnowAmount": 0, + "probOfPrecipitation": 67 + }, + { + "time": "2024-11-23T13:00Z", + "screenTemperature": 7.87, + "maxScreenAirTemp": 7.9, + "minScreenAirTemp": 7.84, + "screenDewPointTemperature": 7.1, + "feelsLikeTemperature": 3.25, + "windSpeed10m": 10.52, + "windDirectionFrom10m": 178, + "windGustSpeed10m": 19.06, + "max10mWindGust": 19.16, + "visibility": 8196, + "screenRelativeHumidity": 94.78, + "mslp": 99680, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.86, + "totalPrecipAmount": 0.29, + "totalSnowAmount": 0, + "probOfPrecipitation": 57 + }, + { + "time": "2024-11-23T14:00Z", + "screenTemperature": 8.34, + "maxScreenAirTemp": 8.34, + "minScreenAirTemp": 7.87, + "screenDewPointTemperature": 7.32, + "feelsLikeTemperature": 4, + "windSpeed10m": 10, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 18.66, + "max10mWindGust": 18.98, + "visibility": 9417, + "screenRelativeHumidity": 93.17, + "mslp": 99550, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.23, + "totalPrecipAmount": 0.06, + "totalSnowAmount": 0, + "probOfPrecipitation": 62 + }, + { + "time": "2024-11-23T15:00Z", + "screenTemperature": 9.11, + "maxScreenAirTemp": 9.13, + "minScreenAirTemp": 8.34, + "screenDewPointTemperature": 8.03, + "feelsLikeTemperature": 5.14, + "windSpeed10m": 9.45, + "windDirectionFrom10m": 183, + "windGustSpeed10m": 17.94, + "max10mWindGust": 18.36, + "visibility": 8865, + "screenRelativeHumidity": 92.81, + "mslp": 99406, + "uvIndex": 1, + "significantWeatherCode": 15, + "precipitationRate": 1.87, + "totalPrecipAmount": 0.48, + "totalSnowAmount": 0, + "probOfPrecipitation": 93 + }, + { + "time": "2024-11-23T16:00Z", + "screenTemperature": 10.16, + "maxScreenAirTemp": 10.17, + "minScreenAirTemp": 9.11, + "screenDewPointTemperature": 9.02, + "feelsLikeTemperature": 6.38, + "windSpeed10m": 9.8, + "windDirectionFrom10m": 186, + "windGustSpeed10m": 18.67, + "max10mWindGust": 19.04, + "visibility": 16945, + "screenRelativeHumidity": 92.66, + "mslp": 99301, + "uvIndex": 0, + "significantWeatherCode": 15, + "precipitationRate": 4.03, + "totalPrecipAmount": 1.14, + "totalSnowAmount": 0, + "probOfPrecipitation": 95 + }, + { + "time": "2024-11-23T17:00Z", + "screenTemperature": 11.07, + "maxScreenAirTemp": 11.08, + "minScreenAirTemp": 10.16, + "screenDewPointTemperature": 9.94, + "feelsLikeTemperature": 7.46, + "windSpeed10m": 9.41, + "windDirectionFrom10m": 193, + "windGustSpeed10m": 18.09, + "max10mWindGust": 18.86, + "visibility": 9798, + "screenRelativeHumidity": 92.69, + "mslp": 99270, + "uvIndex": 0, + "significantWeatherCode": 15, + "precipitationRate": 2.26, + "totalPrecipAmount": 0.24, + "totalSnowAmount": 0, + "probOfPrecipitation": 93 + }, + { + "time": "2024-11-23T18:00Z", + "screenTemperature": 11.94, + "maxScreenAirTemp": 11.95, + "minScreenAirTemp": 11.07, + "screenDewPointTemperature": 10.9, + "feelsLikeTemperature": 8.72, + "windSpeed10m": 8.19, + "windDirectionFrom10m": 200, + "windGustSpeed10m": 16.15, + "max10mWindGust": 17.4, + "visibility": 10545, + "screenRelativeHumidity": 93.31, + "mslp": 99260, + "uvIndex": 0, + "significantWeatherCode": 15, + "precipitationRate": 2.51, + "totalPrecipAmount": 0.88, + "totalSnowAmount": 0, + "probOfPrecipitation": 93 + }, + { + "time": "2024-11-23T19:00Z", + "screenTemperature": 13.3, + "maxScreenAirTemp": 13.31, + "minScreenAirTemp": 11.94, + "screenDewPointTemperature": 11.95, + "feelsLikeTemperature": 10.09, + "windSpeed10m": 8.35, + "windDirectionFrom10m": 208, + "windGustSpeed10m": 16.37, + "max10mWindGust": 16.41, + "visibility": 36868, + "screenRelativeHumidity": 91.45, + "mslp": 99264, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 11 + }, + { + "time": "2024-11-23T20:00Z", + "screenTemperature": 13.56, + "maxScreenAirTemp": 13.58, + "minScreenAirTemp": 13.3, + "screenDewPointTemperature": 12.29, + "feelsLikeTemperature": 10.34, + "windSpeed10m": 8.42, + "windDirectionFrom10m": 205, + "windGustSpeed10m": 16.18, + "max10mWindGust": 16.75, + "visibility": 28041, + "screenRelativeHumidity": 91.94, + "mslp": 99304, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 27 + }, + { + "time": "2024-11-23T21:00Z", + "screenTemperature": 13.81, + "maxScreenAirTemp": 13.82, + "minScreenAirTemp": 13.56, + "screenDewPointTemperature": 12.5, + "feelsLikeTemperature": 10.53, + "windSpeed10m": 8.6, + "windDirectionFrom10m": 205, + "windGustSpeed10m": 16.28, + "max10mWindGust": 16.62, + "visibility": 29418, + "screenRelativeHumidity": 91.67, + "mslp": 99363, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 1.07, + "totalPrecipAmount": 0.21, + "totalSnowAmount": 0, + "probOfPrecipitation": 63 + }, + { + "time": "2024-11-23T22:00Z", + "screenTemperature": 14.07, + "maxScreenAirTemp": 14.08, + "minScreenAirTemp": 13.81, + "screenDewPointTemperature": 12.65, + "feelsLikeTemperature": 10.85, + "windSpeed10m": 8.42, + "windDirectionFrom10m": 204, + "windGustSpeed10m": 16.18, + "max10mWindGust": 16.85, + "visibility": 42192, + "screenRelativeHumidity": 91.08, + "mslp": 99382, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 14 + }, + { + "time": "2024-11-23T23:00Z", + "screenTemperature": 14.08, + "maxScreenAirTemp": 14.12, + "minScreenAirTemp": 14.05, + "screenDewPointTemperature": 12.78, + "feelsLikeTemperature": 10.96, + "windSpeed10m": 8.16, + "windDirectionFrom10m": 196, + "windGustSpeed10m": 15.48, + "max10mWindGust": 16.29, + "visibility": 23225, + "screenRelativeHumidity": 91.85, + "mslp": 99372, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.89, + "totalPrecipAmount": 0.11, + "totalSnowAmount": 0, + "probOfPrecipitation": 61 + }, + { + "time": "2024-11-24T00:00Z", + "screenTemperature": 14.21, + "maxScreenAirTemp": 14.25, + "minScreenAirTemp": 14.08, + "screenDewPointTemperature": 12.64, + "feelsLikeTemperature": 10.87, + "windSpeed10m": 8.72, + "windDirectionFrom10m": 199, + "windGustSpeed10m": 16.6, + "max10mWindGust": 16.69, + "visibility": 42290, + "screenRelativeHumidity": 90.27, + "mslp": 99344, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 24 + }, + { + "time": "2024-11-24T01:00Z", + "screenTemperature": 14.28, + "maxScreenAirTemp": 14.3, + "minScreenAirTemp": 14.21, + "screenDewPointTemperature": 12.72, + "feelsLikeTemperature": 10.74, + "windSpeed10m": 9.29, + "windDirectionFrom10m": 199, + "windGustSpeed10m": 17.46, + "max10mWindGust": 17.85, + "visibility": 33325, + "screenRelativeHumidity": 90.21, + "mslp": 99303, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 19 + }, + { + "time": "2024-11-24T02:00Z", + "screenTemperature": 14.23, + "maxScreenAirTemp": 14.29, + "minScreenAirTemp": 14.19, + "screenDewPointTemperature": 12.69, + "feelsLikeTemperature": 10.54, + "windSpeed10m": 9.65, + "windDirectionFrom10m": 197, + "windGustSpeed10m": 18.14, + "max10mWindGust": 19.37, + "visibility": 20882, + "screenRelativeHumidity": 90.42, + "mslp": 99282, + "uvIndex": 0, + "significantWeatherCode": 13, + "precipitationRate": 0.89, + "totalPrecipAmount": 0.16, + "totalSnowAmount": 0, + "probOfPrecipitation": 70 + }, + { + "time": "2024-11-24T03:00Z", + "screenTemperature": 14.42, + "maxScreenAirTemp": 14.43, + "minScreenAirTemp": 14.23, + "screenDewPointTemperature": 12.72, + "feelsLikeTemperature": 10.6, + "windSpeed10m": 9.95, + "windDirectionFrom10m": 198, + "windGustSpeed10m": 18.53, + "max10mWindGust": 19.32, + "visibility": 32364, + "screenRelativeHumidity": 89.41, + "mslp": 99242, + "uvIndex": 0, + "significantWeatherCode": 9, + "precipitationRate": 0.1, + "totalPrecipAmount": 0.06, + "totalSnowAmount": 0, + "probOfPrecipitation": 31 + }, + { + "time": "2024-11-24T04:00Z", + "screenTemperature": 14.51, + "maxScreenAirTemp": 14.58, + "minScreenAirTemp": 14.42, + "screenDewPointTemperature": 12.6, + "feelsLikeTemperature": 10.63, + "windSpeed10m": 10.05, + "windDirectionFrom10m": 196, + "windGustSpeed10m": 18.86, + "max10mWindGust": 19.09, + "visibility": 15355, + "screenRelativeHumidity": 88.25, + "mslp": 99212, + "uvIndex": 0, + "significantWeatherCode": 9, + "precipitationRate": 0.38, + "totalPrecipAmount": 0.23, + "totalSnowAmount": 0, + "probOfPrecipitation": 40 + }, + { + "time": "2024-11-24T05:00Z", + "screenTemperature": 14.48, + "maxScreenAirTemp": 14.52, + "minScreenAirTemp": 14.47, + "screenDewPointTemperature": 12.37, + "feelsLikeTemperature": 10.53, + "windSpeed10m": 10.16, + "windDirectionFrom10m": 195, + "windGustSpeed10m": 18.76, + "max10mWindGust": 18.81, + "visibility": 29205, + "screenRelativeHumidity": 87.08, + "mslp": 99183, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 22 + }, + { + "time": "2024-11-24T06:00Z", + "screenTemperature": 14.53, + "maxScreenAirTemp": 14.57, + "minScreenAirTemp": 14.48, + "screenDewPointTemperature": 12.34, + "feelsLikeTemperature": 10.54, + "windSpeed10m": 10.23, + "windDirectionFrom10m": 196, + "windGustSpeed10m": 18.81, + "max10mWindGust": 18.9, + "visibility": 25187, + "screenRelativeHumidity": 86.67, + "mslp": 99182, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 22 + }, + { + "time": "2024-11-24T07:00Z", + "screenTemperature": 14.72, + "maxScreenAirTemp": 14.73, + "minScreenAirTemp": 14.53, + "screenDewPointTemperature": 12.51, + "feelsLikeTemperature": 10.69, + "windSpeed10m": 10.33, + "windDirectionFrom10m": 194, + "windGustSpeed10m": 19, + "max10mWindGust": 19, + "visibility": 31443, + "screenRelativeHumidity": 86.55, + "mslp": 99173, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 15 + }, + { + "time": "2024-11-24T08:00Z", + "screenTemperature": 14.74, + "maxScreenAirTemp": 14.79, + "minScreenAirTemp": 14.71, + "screenDewPointTemperature": 12.36, + "feelsLikeTemperature": 10.7, + "windSpeed10m": 10.27, + "windDirectionFrom10m": 193, + "windGustSpeed10m": 18.91, + "max10mWindGust": 19.17, + "visibility": 24964, + "screenRelativeHumidity": 85.71, + "mslp": 99182, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.52, + "totalPrecipAmount": 0.04, + "totalSnowAmount": 0, + "probOfPrecipitation": 52 + }, + { + "time": "2024-11-24T09:00Z", + "screenTemperature": 14.78, + "maxScreenAirTemp": 14.81, + "minScreenAirTemp": 14.72, + "screenDewPointTemperature": 12.35, + "feelsLikeTemperature": 10.63, + "windSpeed10m": 10.56, + "windDirectionFrom10m": 196, + "windGustSpeed10m": 19.44, + "max10mWindGust": 19.44, + "visibility": 16181, + "screenRelativeHumidity": 85.33, + "mslp": 99173, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.36, + "totalPrecipAmount": 0.2, + "totalSnowAmount": 0, + "probOfPrecipitation": 53 + }, + { + "time": "2024-11-24T10:00Z", + "screenTemperature": 14.88, + "maxScreenAirTemp": 14.91, + "minScreenAirTemp": 14.78, + "screenDewPointTemperature": 12.28, + "feelsLikeTemperature": 10.47, + "windSpeed10m": 11.1, + "windDirectionFrom10m": 198, + "windGustSpeed10m": 20.32, + "max10mWindGust": 20.6, + "visibility": 22668, + "screenRelativeHumidity": 84.58, + "mslp": 99192, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.14, + "totalPrecipAmount": 0.05, + "totalSnowAmount": 0, + "probOfPrecipitation": 42 + }, + { + "time": "2024-11-24T11:00Z", + "screenTemperature": 15.3, + "maxScreenAirTemp": 15.33, + "minScreenAirTemp": 14.88, + "screenDewPointTemperature": 12.49, + "feelsLikeTemperature": 11.03, + "windSpeed10m": 10.55, + "windDirectionFrom10m": 201, + "windGustSpeed10m": 19.58, + "max10mWindGust": 19.77, + "visibility": 26957, + "screenRelativeHumidity": 83.56, + "mslp": 99220, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.2, + "totalPrecipAmount": 0.11, + "totalSnowAmount": 0, + "probOfPrecipitation": 44 + }, + { + "time": "2024-11-24T12:00Z", + "screenTemperature": 15.57, + "maxScreenAirTemp": 15.69, + "minScreenAirTemp": 15.3, + "screenDewPointTemperature": 12.54, + "feelsLikeTemperature": 11.45, + "windSpeed10m": 10.03, + "windDirectionFrom10m": 200, + "windGustSpeed10m": 19, + "max10mWindGust": 19, + "visibility": 19911, + "screenRelativeHumidity": 82.47, + "mslp": 99221, + "uvIndex": 1, + "significantWeatherCode": 15, + "precipitationRate": 0.83, + "totalPrecipAmount": 0.18, + "totalSnowAmount": 0, + "probOfPrecipitation": 81 + }, + { + "time": "2024-11-24T13:00Z", + "screenTemperature": 15.19, + "maxScreenAirTemp": 15.57, + "minScreenAirTemp": 15.16, + "screenDewPointTemperature": 12.23, + "feelsLikeTemperature": 10.93, + "windSpeed10m": 10.47, + "windDirectionFrom10m": 198, + "windGustSpeed10m": 19.58, + "max10mWindGust": 19.58, + "visibility": 23634, + "screenRelativeHumidity": 82.75, + "mslp": 99230, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.66, + "totalPrecipAmount": 0.15, + "totalSnowAmount": 0, + "probOfPrecipitation": 59 + }, + { + "time": "2024-11-24T14:00Z", + "screenTemperature": 15.16, + "maxScreenAirTemp": 15.19, + "minScreenAirTemp": 15.08, + "screenDewPointTemperature": 11.92, + "feelsLikeTemperature": 10.99, + "windSpeed10m": 10.24, + "windDirectionFrom10m": 202, + "windGustSpeed10m": 19.19, + "max10mWindGust": 19.19, + "visibility": 29843, + "screenRelativeHumidity": 81.2, + "mslp": 99230, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, + { + "time": "2024-11-24T15:00Z", + "screenTemperature": 14.97, + "maxScreenAirTemp": 15.16, + "minScreenAirTemp": 14.96, + "screenDewPointTemperature": 11.65, + "feelsLikeTemperature": 10.99, + "windSpeed10m": 9.74, + "windDirectionFrom10m": 203, + "windGustSpeed10m": 18.27, + "max10mWindGust": 18.82, + "visibility": 23608, + "screenRelativeHumidity": 80.72, + "mslp": 99239, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.31, + "totalPrecipAmount": 0.07, + "totalSnowAmount": 0, + "probOfPrecipitation": 45 + }, + { + "time": "2024-11-24T16:00Z", + "screenTemperature": 14.76, + "maxScreenAirTemp": 14.97, + "minScreenAirTemp": 14.71, + "screenDewPointTemperature": 11.45, + "feelsLikeTemperature": 10.96, + "windSpeed10m": 9.42, + "windDirectionFrom10m": 200, + "windGustSpeed10m": 17.72, + "max10mWindGust": 17.84, + "visibility": 30385, + "screenRelativeHumidity": 80.72, + "mslp": 99229, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.18, + "totalPrecipAmount": 0.16, + "totalSnowAmount": 0, + "probOfPrecipitation": 48 + }, + { + "time": "2024-11-24T17:00Z", + "screenTemperature": 14.38, + "maxScreenAirTemp": 14.76, + "minScreenAirTemp": 14.31, + "screenDewPointTemperature": 11.36, + "feelsLikeTemperature": 10.87, + "windSpeed10m": 8.71, + "windDirectionFrom10m": 199, + "windGustSpeed10m": 16.39, + "max10mWindGust": 17.72, + "visibility": 26409, + "screenRelativeHumidity": 82.26, + "mslp": 99211, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.55, + "totalPrecipAmount": 0.51, + "totalSnowAmount": 0, + "probOfPrecipitation": 50 + }, + { + "time": "2024-11-24T18:00Z", + "screenTemperature": 14.27, + "maxScreenAirTemp": 14.38, + "minScreenAirTemp": 14.21, + "screenDewPointTemperature": 11.11, + "feelsLikeTemperature": 10.84, + "windSpeed10m": 8.56, + "windDirectionFrom10m": 196, + "windGustSpeed10m": 16.09, + "max10mWindGust": 16.09, + "visibility": 23645, + "screenRelativeHumidity": 81.33, + "mslp": 99164, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.43, + "totalPrecipAmount": 0.15, + "totalSnowAmount": 0, + "probOfPrecipitation": 55 + }, + { + "time": "2024-11-24T19:00Z", + "screenTemperature": 14.08, + "maxScreenAirTemp": 14.27, + "minScreenAirTemp": 14.07, + "screenDewPointTemperature": 10.51, + "feelsLikeTemperature": 10.35, + "windSpeed10m": 9.18, + "windDirectionFrom10m": 198, + "windGustSpeed10m": 17.08, + "max10mWindGust": 17.08, + "visibility": 28936, + "screenRelativeHumidity": 79.25, + "mslp": 99127, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.3, + "totalPrecipAmount": 0.18, + "totalSnowAmount": 0, + "probOfPrecipitation": 43 + }, + { + "time": "2024-11-24T20:00Z", + "screenTemperature": 13, + "maxScreenAirTemp": 14.08, + "minScreenAirTemp": 12.95, + "screenDewPointTemperature": 10.35, + "feelsLikeTemperature": 9.56, + "windSpeed10m": 8.42, + "windDirectionFrom10m": 215, + "windGustSpeed10m": 15.63, + "max10mWindGust": 16.07, + "visibility": 12200, + "screenRelativeHumidity": 84.28, + "mslp": 99154, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.97, + "totalPrecipAmount": 0.21, + "totalSnowAmount": 0, + "probOfPrecipitation": 56 + }, + { + "time": "2024-11-24T21:00Z", + "screenTemperature": 11.88, + "maxScreenAirTemp": 13, + "minScreenAirTemp": 11.87, + "screenDewPointTemperature": 10.08, + "feelsLikeTemperature": 9.07, + "windSpeed10m": 6.7, + "windDirectionFrom10m": 221, + "windGustSpeed10m": 12.78, + "max10mWindGust": 13.87, + "visibility": 10227, + "screenRelativeHumidity": 88.76, + "mslp": 99182, + "uvIndex": 0, + "significantWeatherCode": 15, + "precipitationRate": 1.04, + "totalPrecipAmount": 0.46, + "totalSnowAmount": 0, + "probOfPrecipitation": 86 + }, + { + "time": "2024-11-24T22:00Z", + "screenTemperature": 11.28, + "maxScreenAirTemp": 11.88, + "minScreenAirTemp": 11.24, + "screenDewPointTemperature": 9.54, + "feelsLikeTemperature": 8.44, + "windSpeed10m": 6.56, + "windDirectionFrom10m": 218, + "windGustSpeed10m": 12.47, + "max10mWindGust": 12.47, + "visibility": 12135, + "screenRelativeHumidity": 89.13, + "mslp": 99229, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.45, + "totalPrecipAmount": 0.35, + "totalSnowAmount": 0, + "probOfPrecipitation": 58 + }, + { + "time": "2024-11-24T23:00Z", + "screenTemperature": 10.8, + "maxScreenAirTemp": 11.28, + "minScreenAirTemp": 10.78, + "screenDewPointTemperature": 8.75, + "feelsLikeTemperature": 7.88, + "windSpeed10m": 6.7, + "windDirectionFrom10m": 212, + "windGustSpeed10m": 12.96, + "max10mWindGust": 12.96, + "visibility": 36419, + "screenRelativeHumidity": 87.18, + "mslp": 99267, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.3, + "totalPrecipAmount": 0.43, + "totalSnowAmount": 0, + "probOfPrecipitation": 52 + }, + { + "time": "2024-11-25T00:00Z", + "screenTemperature": 10.58, + "maxScreenAirTemp": 10.8, + "minScreenAirTemp": 10.56, + "screenDewPointTemperature": 8.06, + "feelsLikeTemperature": 7.78, + "windSpeed10m": 6.3, + "windDirectionFrom10m": 214, + "windGustSpeed10m": 12.27, + "max10mWindGust": 12.27, + "visibility": 44678, + "screenRelativeHumidity": 84.49, + "mslp": 99278, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.25, + "totalPrecipAmount": 0.31, + "totalSnowAmount": 0, + "probOfPrecipitation": 43 + }, + { + "time": "2024-11-25T01:00Z", + "screenTemperature": 10.49, + "maxScreenAirTemp": 10.58, + "minScreenAirTemp": 10.48, + "screenDewPointTemperature": 7.77, + "feelsLikeTemperature": 7.63, + "windSpeed10m": 6.36, + "windDirectionFrom10m": 211, + "windGustSpeed10m": 12.3, + "max10mWindGust": 12.3, + "visibility": 43617, + "screenRelativeHumidity": 83.39, + "mslp": 99278, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.42, + "totalPrecipAmount": 0.23, + "totalSnowAmount": 0, + "probOfPrecipitation": 54 + }, + { + "time": "2024-11-25T02:00Z", + "screenTemperature": 10.18, + "maxScreenAirTemp": 10.49, + "minScreenAirTemp": 10.18, + "screenDewPointTemperature": 7.81, + "feelsLikeTemperature": 7.27, + "windSpeed10m": 6.43, + "windDirectionFrom10m": 204, + "windGustSpeed10m": 12.41, + "max10mWindGust": 12.41, + "visibility": 35252, + "screenRelativeHumidity": 85.21, + "mslp": 99287, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 23 + }, + { + "time": "2024-11-25T03:00Z", + "screenTemperature": 10.14, + "maxScreenAirTemp": 10.18, + "minScreenAirTemp": 10.12, + "screenDewPointTemperature": 7.49, + "feelsLikeTemperature": 7.27, + "windSpeed10m": 6.3, + "windDirectionFrom10m": 202, + "windGustSpeed10m": 12.31, + "max10mWindGust": 12.85, + "visibility": 47099, + "screenRelativeHumidity": 83.6, + "mslp": 99279, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 8 + }, + { + "time": "2024-11-25T04:00Z", + "screenTemperature": 10.13, + "maxScreenAirTemp": 10.17, + "minScreenAirTemp": 10.11, + "screenDewPointTemperature": 7.42, + "feelsLikeTemperature": 7.26, + "windSpeed10m": 6.26, + "windDirectionFrom10m": 205, + "windGustSpeed10m": 12.08, + "max10mWindGust": 12.9, + "visibility": 44698, + "screenRelativeHumidity": 83.37, + "mslp": 99289, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 9 + }, + { + "time": "2024-11-25T05:00Z", + "screenTemperature": 10.09, + "maxScreenAirTemp": 10.13, + "minScreenAirTemp": 10.06, + "screenDewPointTemperature": 7.42, + "feelsLikeTemperature": 7.26, + "windSpeed10m": 6.12, + "windDirectionFrom10m": 206, + "windGustSpeed10m": 11.81, + "max10mWindGust": 12.36, + "visibility": 43814, + "screenRelativeHumidity": 83.54, + "mslp": 99299, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2024-11-25T06:00Z", + "screenTemperature": 9.98, + "maxScreenAirTemp": 10.22, + "minScreenAirTemp": 9.97, + "screenDewPointTemperature": 7.16, + "feelsLikeTemperature": 7.23, + "windSpeed10m": 5.83, + "windDirectionFrom10m": 207, + "windGustSpeed10m": 11.26, + "max10mWindGust": 11.75, + "visibility": 41476, + "screenRelativeHumidity": 82.68, + "mslp": 99327, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2024-11-25T07:00Z", + "screenTemperature": 9.89, + "maxScreenAirTemp": 9.98, + "minScreenAirTemp": 9.87, + "screenDewPointTemperature": 7.04, + "feelsLikeTemperature": 7.13, + "windSpeed10m": 5.82, + "windDirectionFrom10m": 211, + "windGustSpeed10m": 11.19, + "max10mWindGust": 11.19, + "visibility": 39207, + "screenRelativeHumidity": 82.5, + "mslp": 99379, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2024-11-25T08:00Z", + "screenTemperature": 9.76, + "maxScreenAirTemp": 9.89, + "minScreenAirTemp": 9.76, + "screenDewPointTemperature": 6.73, + "feelsLikeTemperature": 6.95, + "windSpeed10m": 5.85, + "windDirectionFrom10m": 215, + "windGustSpeed10m": 11.33, + "max10mWindGust": 11.33, + "visibility": 38949, + "screenRelativeHumidity": 81.47, + "mslp": 99458, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2024-11-25T09:00Z", + "screenTemperature": 9.74, + "maxScreenAirTemp": 9.77, + "minScreenAirTemp": 9.74, + "screenDewPointTemperature": 6.68, + "feelsLikeTemperature": 6.87, + "windSpeed10m": 6.07, + "windDirectionFrom10m": 218, + "windGustSpeed10m": 11.44, + "max10mWindGust": 11.44, + "visibility": 38081, + "screenRelativeHumidity": 81.26, + "mslp": 99536, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2024-11-25T10:00Z", + "screenTemperature": 10.07, + "maxScreenAirTemp": 10.07, + "minScreenAirTemp": 9.74, + "screenDewPointTemperature": 6.4, + "feelsLikeTemperature": 7.15, + "windSpeed10m": 6.35, + "windDirectionFrom10m": 223, + "windGustSpeed10m": 11.73, + "max10mWindGust": 11.73, + "visibility": 37260, + "screenRelativeHumidity": 78.14, + "mslp": 99596, + "uvIndex": 1, + "significantWeatherCode": 1, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, + { + "time": "2024-11-25T11:00Z", + "screenTemperature": 10.37, + "maxScreenAirTemp": 10.42, + "minScreenAirTemp": 10.07, + "screenDewPointTemperature": 5.91, + "feelsLikeTemperature": 7.4, + "windSpeed10m": 6.62, + "windDirectionFrom10m": 228, + "windGustSpeed10m": 12.04, + "max10mWindGust": 12.04, + "visibility": 37321, + "screenRelativeHumidity": 74, + "mslp": 99664, + "uvIndex": 1, + "significantWeatherCode": 1, + "precipitationRate": 0, + "totalPrecipAmount": 0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, + { + "time": "2024-11-25T12:00Z", + "screenTemperature": 10.72, + "screenDewPointTemperature": 5.47, + "feelsLikeTemperature": 7.72, + "windSpeed10m": 6.91, + "windDirectionFrom10m": 233, + "windGustSpeed10m": 12.61, + "visibility": 38960, + "screenRelativeHumidity": 70.02, + "mslp": 99715, + "uvIndex": 1, + "significantWeatherCode": 1, + "precipitationRate": 0, + "probOfPrecipitation": 1 + } + ] + } + } + ], + "parameters": [ + { + "totalSnowAmount": { + "type": "Parameter", + "description": "Total Snow Amount Over Previous Hour", + "unit": { + "label": "millimetres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm" + } + } + }, + "screenTemperature": { + "type": "Parameter", + "description": "Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "visibility": { + "type": "Parameter", + "description": "Visibility", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "windDirectionFrom10m": { + "type": "Parameter", + "description": "10m Wind From Direction", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "precipitationRate": { + "type": "Parameter", + "description": "Precipitation Rate", + "unit": { + "label": "millimetres per hour", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm/h" + } + } + }, + "maxScreenAirTemp": { + "type": "Parameter", + "description": "Maximum Screen Air Temperature Over Previous Hour", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "feelsLikeTemperature": { + "type": "Parameter", + "description": "Feels Like Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "screenDewPointTemperature": { + "type": "Parameter", + "description": "Screen Dew Point Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "screenRelativeHumidity": { + "type": "Parameter", + "description": "Screen Relative Humidity", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "windSpeed10m": { + "type": "Parameter", + "description": "10m Wind Speed", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "probOfPrecipitation": { + "type": "Parameter", + "description": "Probability of Precipitation", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "max10mWindGust": { + "type": "Parameter", + "description": "Maximum 10m Wind Gust Speed Over Previous Hour", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "significantWeatherCode": { + "type": "Parameter", + "description": "Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://datahub.metoffice.gov.uk/", + "type": "1" + } + } + }, + "minScreenAirTemp": { + "type": "Parameter", + "description": "Minimum Screen Air Temperature Over Previous Hour", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "totalPrecipAmount": { + "type": "Parameter", + "description": "Total Precipitation Amount Over Previous Hour", + "unit": { + "label": "millimetres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm" + } + } + }, + "mslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "windGustSpeed10m": { + "type": "Parameter", + "description": "10m Wind Gust Speed", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "uvIndex": { + "type": "Parameter", + "description": "UV Index", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "1" + } + } + } + } + ] } } diff --git a/tests/components/metoffice/snapshots/test_weather.ambr b/tests/components/metoffice/snapshots/test_weather.ambr index 0bbc0e06a0a..74b54d1bc2f 100644 --- a/tests/components/metoffice/snapshots/test_weather.ambr +++ b/tests/components/metoffice/snapshots/test_weather.ambr @@ -1,39 +1,91 @@ # serializer version: 1 # name: test_forecast_service[get_forecasts] dict({ - 'weather.met_office_wavertree_daily': dict({ + 'weather.met_office_wavertree': dict({ 'forecast': list([ dict({ + 'apparent_temperature': 9.2, 'condition': 'cloudy', - 'datetime': '2020-04-26T12:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 13.0, - 'wind_bearing': 'WNW', - 'wind_speed': 14.48, + 'datetime': '2024-11-24T00:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 26, + 'pressure': 987.48, + 'temperature': 12.7, + 'templow': 8.2, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 42.66, + 'wind_speed': 23.94, }), dict({ + 'apparent_temperature': 5.3, 'condition': 'partlycloudy', - 'datetime': '2020-04-27T12:00:00+00:00', - 'precipitation_probability': 14, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, + 'datetime': '2024-11-25T00:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 5, + 'pressure': 994.88, + 'temperature': 9.8, + 'templow': 7.7, + 'uv_index': 1, + 'wind_bearing': 251, + 'wind_gust_speed': 52.16, + 'wind_speed': 30.67, }), dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T12:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 12.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, + 'apparent_temperature': 5.9, + 'condition': 'partlycloudy', + 'datetime': '2024-11-26T00:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 6, + 'pressure': 1012.93, + 'temperature': 8.7, + 'templow': 3.8, + 'uv_index': 1, + 'wind_bearing': 265, + 'wind_gust_speed': 34.49, + 'wind_speed': 20.45, }), dict({ + 'apparent_temperature': 3.3, 'condition': 'rainy', - 'datetime': '2020-04-29T12:00:00+00:00', - 'precipitation_probability': 59, - 'temperature': 13.0, - 'wind_bearing': 'SE', - 'wind_speed': 20.92, + 'datetime': '2024-11-27T00:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 43, + 'pressure': 1014.39, + 'temperature': 6.7, + 'templow': 2.4, + 'uv_index': 1, + 'wind_bearing': 8, + 'wind_gust_speed': 32.18, + 'wind_speed': 18.54, + }), + dict({ + 'apparent_temperature': 3.0, + 'condition': 'cloudy', + 'datetime': '2024-11-28T00:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 9, + 'pressure': 1025.12, + 'temperature': 5.7, + 'templow': 3.8, + 'uv_index': 1, + 'wind_bearing': 104, + 'wind_gust_speed': 22.36, + 'wind_speed': 12.64, + }), + dict({ + 'apparent_temperature': 4.9, + 'condition': 'cloudy', + 'datetime': '2024-11-29T00:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 11, + 'pressure': 1019.85, + 'temperature': 8.2, + 'templow': 7.0, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 38.59, + 'wind_speed': 23.0, }), ]), }), @@ -41,287 +93,631 @@ # --- # name: test_forecast_service[get_forecasts].1 dict({ - 'weather.met_office_wavertree_daily': dict({ + 'weather.met_office_wavertree': dict({ 'forecast': list([ dict({ - 'condition': 'sunny', - 'datetime': '2020-04-25T15:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 19.0, - 'wind_bearing': 'S', - 'wind_speed': 6.44, + 'apparent_temperature': 6.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T13:00:00+00:00', + 'precipitation': 0.52, + 'precipitation_probability': 65, + 'pressure': 986.83, + 'temperature': 9.9, + 'uv_index': 1, + 'wind_bearing': 178, + 'wind_gust_speed': 55.73, + 'wind_speed': 25.42, }), dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-25T18:00:00+00:00', - 'precipitation_probability': 2, - 'temperature': 17.0, - 'wind_bearing': 'WNW', - 'wind_speed': 11.27, + 'apparent_temperature': 8.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 12, + 'pressure': 986.34, + 'temperature': 11.1, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 49.0, + 'wind_speed': 22.86, }), dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-25T21:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 14.0, - 'wind_bearing': 'NW', - 'wind_speed': 3.22, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-26T00:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 13.0, - 'wind_bearing': 'WSW', - 'wind_speed': 3.22, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-26T03:00:00+00:00', - 'precipitation_probability': 2, + 'apparent_temperature': 9.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T15:00:00+00:00', + 'precipitation': 0.09, + 'precipitation_probability': 37, + 'pressure': 986.13, 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, + 'uv_index': 1, + 'wind_bearing': 182, + 'wind_gust_speed': 40.1, + 'wind_speed': 18.5, }), dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T06:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, + 'apparent_temperature': 10.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T16:00:00+00:00', + 'precipitation': 0.27, + 'precipitation_probability': 36, + 'pressure': 986.6, + 'temperature': 12.6, + 'uv_index': 0, + 'wind_bearing': 197, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.44, }), dict({ + 'apparent_temperature': 11.3, 'condition': 'cloudy', - 'datetime': '2020-04-26T09:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T12:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T15:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 12.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T18:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T21:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-27T00:00:00+00:00', + 'datetime': '2024-11-23T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 11, - 'temperature': 9.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, + 'pressure': 987.1, + 'temperature': 12.9, + 'uv_index': 0, + 'wind_bearing': 203, + 'wind_gust_speed': 35.57, + 'wind_speed': 15.59, }), dict({ + 'apparent_temperature': 11.3, 'condition': 'cloudy', - 'datetime': '2020-04-27T03:00:00+00:00', - 'precipitation_probability': 12, - 'temperature': 8.0, - 'wind_bearing': 'WNW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-27T06:00:00+00:00', - 'precipitation_probability': 14, - 'temperature': 8.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T09:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T12:00:00+00:00', - 'precipitation_probability': 4, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-27T15:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-27T18:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-27T21:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 9.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-28T00:00:00+00:00', - 'precipitation_probability': 2, - 'temperature': 8.0, - 'wind_bearing': 'NNW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-28T03:00:00+00:00', - 'precipitation_probability': 3, - 'temperature': 7.0, - 'wind_bearing': 'W', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-28T06:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 6.0, - 'wind_bearing': 'S', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-28T09:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T12:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 11.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T15:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 12.0, - 'wind_bearing': 'N', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T18:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 11.0, - 'wind_bearing': 'N', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T21:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 10.0, - 'wind_bearing': 'NNE', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T00:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'E', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-29T03:00:00+00:00', - 'precipitation_probability': 3, - 'temperature': 8.0, - 'wind_bearing': 'SSE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T06:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 8.0, - 'wind_bearing': 'SE', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T09:00:00+00:00', - 'precipitation_probability': 12, - 'temperature': 10.0, - 'wind_bearing': 'SE', - 'wind_speed': 17.7, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2020-04-29T12:00:00+00:00', - 'precipitation_probability': 47, - 'temperature': 12.0, - 'wind_bearing': 'SE', - 'wind_speed': 20.92, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2020-04-29T15:00:00+00:00', - 'precipitation_probability': 59, + 'datetime': '2024-11-23T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 7, + 'pressure': 987.1, 'temperature': 13.0, - 'wind_bearing': 'SSE', - 'wind_speed': 20.92, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 31.21, + 'wind_speed': 15.52, }), dict({ - 'condition': 'rainy', - 'datetime': '2020-04-29T18:00:00+00:00', - 'precipitation_probability': 39, - 'temperature': 12.0, - 'wind_bearing': 'SSE', - 'wind_speed': 17.7, + 'apparent_temperature': 11.1, + 'condition': 'pouring', + 'datetime': '2024-11-23T19:00:00+00:00', + 'precipitation': 0.51, + 'precipitation_probability': 74, + 'pressure': 986.82, + 'temperature': 13.0, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 37.44, + 'wind_speed': 17.46, }), dict({ + 'apparent_temperature': 11.2, 'condition': 'cloudy', - 'datetime': '2020-04-29T21:00:00+00:00', - 'precipitation_probability': 19, + 'datetime': '2024-11-23T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 11, + 'pressure': 986.92, + 'temperature': 13.7, + 'uv_index': 0, + 'wind_bearing': 187, + 'wind_gust_speed': 45.97, + 'wind_speed': 22.72, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'rainy', + 'datetime': '2024-11-23T21:00:00+00:00', + 'precipitation': 0.11, + 'precipitation_probability': 30, + 'pressure': 986.82, + 'temperature': 14.0, + 'uv_index': 0, + 'wind_bearing': 178, + 'wind_gust_speed': 44.32, + 'wind_speed': 22.0, + }), + dict({ + 'apparent_temperature': 11.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 12, + 'pressure': 986.31, + 'temperature': 14.0, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 47.84, + 'wind_speed': 23.65, + }), + dict({ + 'apparent_temperature': 11.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'pressure': 985.71, + 'temperature': 14.3, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 51.44, + 'wind_speed': 26.57, + }), + dict({ + 'apparent_temperature': 11.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'pressure': 984.92, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 50.69, + 'wind_speed': 26.78, + }), + dict({ + 'apparent_temperature': 11.6, + 'condition': 'rainy', + 'datetime': '2024-11-24T01:00:00+00:00', + 'precipitation': 0.17, + 'precipitation_probability': 40, + 'pressure': 984.22, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 50.11, + 'wind_speed': 25.78, + }), + dict({ + 'apparent_temperature': 11.3, + 'condition': 'pouring', + 'datetime': '2024-11-24T02:00:00+00:00', + 'precipitation': 0.21, + 'precipitation_probability': 74, + 'pressure': 983.51, + 'temperature': 14.2, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 52.06, + 'wind_speed': 26.89, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'pouring', + 'datetime': '2024-11-24T03:00:00+00:00', + 'precipitation': 0.34, + 'precipitation_probability': 73, + 'pressure': 983.1, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 187, + 'wind_gust_speed': 51.55, + 'wind_speed': 26.1, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'rainy', + 'datetime': '2024-11-24T04:00:00+00:00', + 'precipitation': 0.28, + 'precipitation_probability': 50, + 'pressure': 983.1, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 49.68, + 'wind_speed': 25.52, + }), + dict({ + 'apparent_temperature': 11.8, + 'condition': 'rainy', + 'datetime': '2024-11-24T05:00:00+00:00', + 'precipitation': 0.25, + 'precipitation_probability': 47, + 'pressure': 983.3, + 'temperature': 14.3, + 'uv_index': 0, + 'wind_bearing': 202, + 'wind_gust_speed': 45.72, + 'wind_speed': 23.69, + }), + dict({ + 'apparent_temperature': 10.7, + 'condition': 'rainy', + 'datetime': '2024-11-24T06:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 42, + 'pressure': 983.96, + 'temperature': 13.4, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 45.83, + 'wind_speed': 24.16, + }), + dict({ + 'apparent_temperature': 10.1, + 'condition': 'rainy', + 'datetime': '2024-11-24T07:00:00+00:00', + 'precipitation': 0.16, + 'precipitation_probability': 40, + 'pressure': 984.58, + 'temperature': 12.5, + 'uv_index': 0, + 'wind_bearing': 214, + 'wind_gust_speed': 39.71, + 'wind_speed': 20.59, + }), + dict({ + 'apparent_temperature': 9.5, + 'condition': 'rainy', + 'datetime': '2024-11-24T08:00:00+00:00', + 'precipitation': 0.08, + 'precipitation_probability': 38, + 'pressure': 985.48, + 'temperature': 11.9, + 'uv_index': 0, + 'wind_bearing': 209, + 'wind_gust_speed': 37.08, + 'wind_speed': 19.73, + }), + dict({ + 'apparent_temperature': 9.1, + 'condition': 'rainy', + 'datetime': '2024-11-24T09:00:00+00:00', + 'precipitation': 0.04, + 'precipitation_probability': 26, + 'pressure': 986.38, + 'temperature': 11.5, + 'uv_index': 1, + 'wind_bearing': 201, + 'wind_gust_speed': 35.96, + 'wind_speed': 19.58, + }), + dict({ + 'apparent_temperature': 9.0, + 'condition': 'rainy', + 'datetime': '2024-11-24T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'pressure': 986.96, + 'temperature': 11.5, + 'uv_index': 1, + 'wind_bearing': 199, + 'wind_gust_speed': 37.01, + 'wind_speed': 20.59, + }), + dict({ + 'apparent_temperature': 9.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 6, + 'pressure': 987.55, + 'temperature': 11.7, + 'uv_index': 1, + 'wind_bearing': 199, + 'wind_gust_speed': 36.22, + 'wind_speed': 20.45, + }), + dict({ + 'apparent_temperature': 9.0, + 'condition': 'cloudy', + 'datetime': '2024-11-24T12:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.48, + 'temperature': 11.8, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 42.66, + 'wind_speed': 23.94, + }), + dict({ + 'apparent_temperature': 8.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T13:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.57, + 'temperature': 11.8, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 45.36, + 'wind_speed': 25.45, + }), + dict({ + 'apparent_temperature': 8.6, + 'condition': 'cloudy', + 'datetime': '2024-11-24T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.37, + 'temperature': 11.7, + 'uv_index': 1, + 'wind_bearing': 201, + 'wind_gust_speed': 46.94, + 'wind_speed': 26.39, + }), + dict({ + 'apparent_temperature': 8.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T15:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 987.27, + 'temperature': 11.6, + 'uv_index': 1, + 'wind_bearing': 198, + 'wind_gust_speed': 46.87, + 'wind_speed': 26.35, + }), + dict({ + 'apparent_temperature': 8.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T16:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.08, + 'temperature': 11.2, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 46.22, + 'wind_speed': 25.38, + }), + dict({ + 'apparent_temperature': 8.0, + 'condition': 'cloudy', + 'datetime': '2024-11-24T17:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 986.89, 'temperature': 11.0, - 'wind_bearing': 'SSE', - 'wind_speed': 20.92, + 'uv_index': 0, + 'wind_bearing': 194, + 'wind_gust_speed': 45.68, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 7.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.7, + 'temperature': 10.9, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 46.15, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 7.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T19:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.69, + 'temperature': 10.8, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 45.43, + 'wind_speed': 24.95, + }), + dict({ + 'apparent_temperature': 7.7, + 'condition': 'cloudy', + 'datetime': '2024-11-24T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.78, + 'temperature': 10.7, + 'uv_index': 0, + 'wind_bearing': 202, + 'wind_gust_speed': 45.07, + 'wind_speed': 24.55, + }), + dict({ + 'apparent_temperature': 7.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.77, + 'temperature': 10.5, + 'uv_index': 0, + 'wind_bearing': 203, + 'wind_gust_speed': 46.4, + 'wind_speed': 25.49, + }), + dict({ + 'apparent_temperature': 7.3, + 'condition': 'cloudy', + 'datetime': '2024-11-24T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 987.04, + 'temperature': 10.5, + 'uv_index': 0, + 'wind_bearing': 204, + 'wind_gust_speed': 48.24, + 'wind_speed': 26.68, + }), + dict({ + 'apparent_temperature': 7.1, + 'condition': 'cloudy', + 'datetime': '2024-11-24T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.04, + 'temperature': 10.3, + 'uv_index': 0, + 'wind_bearing': 207, + 'wind_gust_speed': 50.44, + 'wind_speed': 27.72, + }), + dict({ + 'apparent_temperature': 7.1, + 'condition': 'cloudy', + 'datetime': '2024-11-25T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 23, + 'pressure': 987.12, + 'temperature': 10.2, + 'uv_index': 0, + 'wind_bearing': 211, + 'wind_gust_speed': 47.2, + 'wind_speed': 26.39, + }), + dict({ + 'apparent_temperature': 6.9, + 'condition': 'cloudy', + 'datetime': '2024-11-25T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 11, + 'pressure': 987.41, + 'temperature': 10.0, + 'uv_index': 0, + 'wind_bearing': 215, + 'wind_gust_speed': 45.04, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 6.4, + 'condition': 'cloudy', + 'datetime': '2024-11-25T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 7, + 'pressure': 987.88, + 'temperature': 9.6, + 'uv_index': 0, + 'wind_bearing': 222, + 'wind_gust_speed': 46.87, + 'wind_speed': 25.7, + }), + dict({ + 'apparent_temperature': 6.1, + 'condition': 'cloudy', + 'datetime': '2024-11-25T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 988.16, + 'temperature': 9.3, + 'uv_index': 0, + 'wind_bearing': 226, + 'wind_gust_speed': 44.71, + 'wind_speed': 24.88, + }), + dict({ + 'apparent_temperature': 5.8, + 'condition': 'cloudy', + 'datetime': '2024-11-25T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 988.58, + 'temperature': 9.1, + 'uv_index': 0, + 'wind_bearing': 228, + 'wind_gust_speed': 45.22, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 5.4, + 'condition': 'clear-night', + 'datetime': '2024-11-25T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 989.1, + 'temperature': 8.8, + 'uv_index': 0, + 'wind_bearing': 232, + 'wind_gust_speed': 47.23, + 'wind_speed': 26.14, + }), + dict({ + 'apparent_temperature': 5.1, + 'condition': 'clear-night', + 'datetime': '2024-11-25T06:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 989.61, + 'temperature': 8.7, + 'uv_index': 0, + 'wind_bearing': 235, + 'wind_gust_speed': 48.2, + 'wind_speed': 26.35, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'clear-night', + 'datetime': '2024-11-25T07:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 990.61, + 'temperature': 8.6, + 'uv_index': 0, + 'wind_bearing': 240, + 'wind_gust_speed': 47.81, + 'wind_speed': 26.78, + }), + dict({ + 'apparent_temperature': 4.8, + 'condition': 'clear-night', + 'datetime': '2024-11-25T08:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 991.61, + 'temperature': 8.4, + 'uv_index': 0, + 'wind_bearing': 243, + 'wind_gust_speed': 47.56, + 'wind_speed': 26.86, + }), + dict({ + 'apparent_temperature': 4.8, + 'condition': 'sunny', + 'datetime': '2024-11-25T09:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 992.52, + 'temperature': 8.4, + 'uv_index': 1, + 'wind_bearing': 243, + 'wind_gust_speed': 47.84, + 'wind_speed': 27.32, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 993.42, + 'temperature': 8.7, + 'uv_index': 1, + 'wind_bearing': 245, + 'wind_gust_speed': 49.79, + 'wind_speed': 28.8, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 3, + 'pressure': 994.24, + 'temperature': 8.8, + 'uv_index': 1, + 'wind_bearing': 249, + 'wind_gust_speed': 52.09, + 'wind_speed': 30.38, + }), + dict({ + 'apparent_temperature': 5.2, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T12:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 2, + 'pressure': 994.88, + 'temperature': 8.9, + 'uv_index': 1, + 'wind_bearing': 251, + 'wind_gust_speed': 52.16, + 'wind_speed': 30.67, }), ]), }), @@ -329,39 +725,187 @@ # --- # name: test_forecast_service[get_forecasts].2 dict({ - 'weather.met_office_wavertree_daily': dict({ + 'weather.met_office_wavertree': dict({ 'forecast': list([ dict({ + 'apparent_temperature': 4.8, 'condition': 'cloudy', - 'datetime': '2020-04-26T12:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 13.0, - 'wind_bearing': 'WNW', - 'wind_speed': 14.48, + 'datetime': '2024-11-24T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 23, + 'pressure': 987.12, + 'temperature': 10.7, + 'templow': 7.0, + 'uv_index': None, + 'wind_bearing': 211, + 'wind_gust_speed': 47.2, + 'wind_speed': 26.39, }), dict({ + 'apparent_temperature': 9.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 26, + 'pressure': 987.48, + 'temperature': 15.2, + 'templow': 11.9, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 42.66, + 'wind_speed': 23.94, + }), + dict({ + 'apparent_temperature': 4.2, 'condition': 'partlycloudy', - 'datetime': '2020-04-27T12:00:00+00:00', - 'precipitation_probability': 14, + 'datetime': '2024-11-25T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 6, + 'pressure': 1004.81, + 'temperature': 9.3, + 'templow': 4.4, + 'uv_index': None, + 'wind_bearing': 262, + 'wind_gust_speed': 47.99, + 'wind_speed': 29.23, + }), + dict({ + 'apparent_temperature': 5.3, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 5, + 'pressure': 994.88, 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, + 'templow': 8.4, + 'uv_index': 1, + 'wind_bearing': 251, + 'wind_gust_speed': 52.16, + 'wind_speed': 30.67, }), dict({ + 'apparent_temperature': 1.3, 'condition': 'cloudy', - 'datetime': '2020-04-28T12:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 12.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, + 'datetime': '2024-11-26T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 44, + 'pressure': 1013.9, + 'temperature': 7.5, + 'templow': -0.4, + 'uv_index': None, + 'wind_bearing': 74, + 'wind_gust_speed': 19.51, + 'wind_speed': 11.41, }), dict({ + 'apparent_temperature': 5.9, + 'condition': 'partlycloudy', + 'datetime': '2024-11-26T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 6, + 'pressure': 1012.93, + 'temperature': 10.1, + 'templow': 6.5, + 'uv_index': 1, + 'wind_bearing': 265, + 'wind_gust_speed': 34.49, + 'wind_speed': 20.45, + }), + dict({ + 'apparent_temperature': 0.2, + 'condition': 'clear-night', + 'datetime': '2024-11-27T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 9, + 'pressure': 1021.75, + 'temperature': 7.2, + 'templow': -3.0, + 'uv_index': None, + 'wind_bearing': 31, + 'wind_gust_speed': 19.94, + 'wind_speed': 11.84, + }), + dict({ + 'apparent_temperature': 3.3, 'condition': 'rainy', - 'datetime': '2020-04-29T12:00:00+00:00', - 'precipitation_probability': 59, - 'temperature': 13.0, - 'wind_bearing': 'SE', - 'wind_speed': 20.92, + 'datetime': '2024-11-27T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 43, + 'pressure': 1014.39, + 'temperature': 11.1, + 'templow': 3.0, + 'uv_index': 1, + 'wind_bearing': 8, + 'wind_gust_speed': 32.18, + 'wind_speed': 18.54, + }), + dict({ + 'apparent_temperature': 1.6, + 'condition': 'cloudy', + 'datetime': '2024-11-28T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 9, + 'pressure': 1023.82, + 'temperature': 8.2, + 'templow': -1.9, + 'uv_index': None, + 'wind_bearing': 131, + 'wind_gust_speed': 33.16, + 'wind_speed': 20.05, + }), + dict({ + 'apparent_temperature': 3.0, + 'condition': 'cloudy', + 'datetime': '2024-11-28T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 9, + 'pressure': 1025.12, + 'temperature': 9.4, + 'templow': 1.3, + 'uv_index': 1, + 'wind_bearing': 104, + 'wind_gust_speed': 22.36, + 'wind_speed': 12.64, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'cloudy', + 'datetime': '2024-11-29T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 13, + 'pressure': 1016.88, + 'temperature': 10.8, + 'templow': -1.9, + 'uv_index': None, + 'wind_bearing': 151, + 'wind_gust_speed': 33.16, + 'wind_speed': 20.12, + }), + dict({ + 'apparent_temperature': 4.9, + 'condition': 'cloudy', + 'datetime': '2024-11-29T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 11, + 'pressure': 1019.85, + 'temperature': 12.6, + 'templow': 4.2, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 38.59, + 'wind_speed': 23.0, }), ]), }), @@ -369,287 +913,91 @@ # --- # name: test_forecast_service[get_forecasts].3 dict({ - 'weather.met_office_wavertree_daily': dict({ + 'weather.met_office_wavertree': dict({ 'forecast': list([ dict({ - 'condition': 'sunny', - 'datetime': '2020-04-25T15:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 19.0, - 'wind_bearing': 'S', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-25T18:00:00+00:00', - 'precipitation_probability': 2, - 'temperature': 17.0, - 'wind_bearing': 'WNW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-25T21:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 14.0, - 'wind_bearing': 'NW', - 'wind_speed': 3.22, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-26T00:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 13.0, - 'wind_bearing': 'WSW', - 'wind_speed': 3.22, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-26T03:00:00+00:00', - 'precipitation_probability': 2, - 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, - }), - dict({ + 'apparent_temperature': 9.2, 'condition': 'cloudy', - 'datetime': '2020-04-26T06:00:00+00:00', + 'datetime': '2024-11-24T00:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 26, + 'pressure': 987.48, + 'temperature': 12.7, + 'templow': 8.2, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 42.66, + 'wind_speed': 23.94, + }), + dict({ + 'apparent_temperature': 5.3, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T00:00:00+00:00', + 'precipitation': None, 'precipitation_probability': 5, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, + 'pressure': 994.88, + 'temperature': 9.8, + 'templow': 7.7, + 'uv_index': 1, + 'wind_bearing': 251, + 'wind_gust_speed': 52.16, + 'wind_speed': 30.67, }), dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T09:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T12:00:00+00:00', + 'apparent_temperature': 5.9, + 'condition': 'partlycloudy', + 'datetime': '2024-11-26T00:00:00+00:00', + 'precipitation': None, 'precipitation_probability': 6, - 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 14.48, + 'pressure': 1012.93, + 'temperature': 8.7, + 'templow': 3.8, + 'uv_index': 1, + 'wind_bearing': 265, + 'wind_gust_speed': 34.49, + 'wind_speed': 20.45, }), dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T15:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 12.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, + 'apparent_temperature': 3.3, + 'condition': 'rainy', + 'datetime': '2024-11-27T00:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 43, + 'pressure': 1014.39, + 'temperature': 6.7, + 'templow': 2.4, + 'uv_index': 1, + 'wind_bearing': 8, + 'wind_gust_speed': 32.18, + 'wind_speed': 18.54, }), dict({ + 'apparent_temperature': 3.0, 'condition': 'cloudy', - 'datetime': '2020-04-26T18:00:00+00:00', + 'datetime': '2024-11-28T00:00:00+00:00', + 'precipitation': None, 'precipitation_probability': 9, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, + 'pressure': 1025.12, + 'temperature': 5.7, + 'templow': 3.8, + 'uv_index': 1, + 'wind_bearing': 104, + 'wind_gust_speed': 22.36, + 'wind_speed': 12.64, }), dict({ + 'apparent_temperature': 4.9, 'condition': 'cloudy', - 'datetime': '2020-04-26T21:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-27T00:00:00+00:00', + 'datetime': '2024-11-29T00:00:00+00:00', + 'precipitation': None, 'precipitation_probability': 11, - 'temperature': 9.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-27T03:00:00+00:00', - 'precipitation_probability': 12, - 'temperature': 8.0, - 'wind_bearing': 'WNW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-27T06:00:00+00:00', - 'precipitation_probability': 14, - 'temperature': 8.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T09:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T12:00:00+00:00', - 'precipitation_probability': 4, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-27T15:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-27T18:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-27T21:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 9.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-28T00:00:00+00:00', - 'precipitation_probability': 2, - 'temperature': 8.0, - 'wind_bearing': 'NNW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-28T03:00:00+00:00', - 'precipitation_probability': 3, - 'temperature': 7.0, - 'wind_bearing': 'W', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-28T06:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 6.0, - 'wind_bearing': 'S', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-28T09:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T12:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 11.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T15:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 12.0, - 'wind_bearing': 'N', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T18:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 11.0, - 'wind_bearing': 'N', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T21:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 10.0, - 'wind_bearing': 'NNE', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T00:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'E', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-29T03:00:00+00:00', - 'precipitation_probability': 3, - 'temperature': 8.0, - 'wind_bearing': 'SSE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T06:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 8.0, - 'wind_bearing': 'SE', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T09:00:00+00:00', - 'precipitation_probability': 12, - 'temperature': 10.0, - 'wind_bearing': 'SE', - 'wind_speed': 17.7, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2020-04-29T12:00:00+00:00', - 'precipitation_probability': 47, - 'temperature': 12.0, - 'wind_bearing': 'SE', - 'wind_speed': 20.92, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2020-04-29T15:00:00+00:00', - 'precipitation_probability': 59, - 'temperature': 13.0, - 'wind_bearing': 'SSE', - 'wind_speed': 20.92, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2020-04-29T18:00:00+00:00', - 'precipitation_probability': 39, - 'temperature': 12.0, - 'wind_bearing': 'SSE', - 'wind_speed': 17.7, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T21:00:00+00:00', - 'precipitation_probability': 19, - 'temperature': 11.0, - 'wind_bearing': 'SSE', - 'wind_speed': 20.92, + 'pressure': 1019.85, + 'temperature': 8.2, + 'templow': 7.0, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 38.59, + 'wind_speed': 23.0, }), ]), }), @@ -657,649 +1005,2077 @@ # --- # name: test_forecast_service[get_forecasts].4 dict({ - 'weather.met_office_wavertree_daily': dict({ + 'weather.met_office_wavertree': dict({ 'forecast': list([ + dict({ + 'apparent_temperature': 6.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T13:00:00+00:00', + 'precipitation': 0.52, + 'precipitation_probability': 65, + 'pressure': 986.83, + 'temperature': 9.9, + 'uv_index': 1, + 'wind_bearing': 178, + 'wind_gust_speed': 55.73, + 'wind_speed': 25.42, + }), + dict({ + 'apparent_temperature': 8.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 12, + 'pressure': 986.34, + 'temperature': 11.1, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 49.0, + 'wind_speed': 22.86, + }), + dict({ + 'apparent_temperature': 9.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T15:00:00+00:00', + 'precipitation': 0.09, + 'precipitation_probability': 37, + 'pressure': 986.13, + 'temperature': 12.0, + 'uv_index': 1, + 'wind_bearing': 182, + 'wind_gust_speed': 40.1, + 'wind_speed': 18.5, + }), + dict({ + 'apparent_temperature': 10.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T16:00:00+00:00', + 'precipitation': 0.27, + 'precipitation_probability': 36, + 'pressure': 986.6, + 'temperature': 12.6, + 'uv_index': 0, + 'wind_bearing': 197, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.44, + }), + dict({ + 'apparent_temperature': 11.3, + 'condition': 'cloudy', + 'datetime': '2024-11-23T17:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 11, + 'pressure': 987.1, + 'temperature': 12.9, + 'uv_index': 0, + 'wind_bearing': 203, + 'wind_gust_speed': 35.57, + 'wind_speed': 15.59, + }), + dict({ + 'apparent_temperature': 11.3, + 'condition': 'cloudy', + 'datetime': '2024-11-23T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 7, + 'pressure': 987.1, + 'temperature': 13.0, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 31.21, + 'wind_speed': 15.52, + }), + dict({ + 'apparent_temperature': 11.1, + 'condition': 'pouring', + 'datetime': '2024-11-23T19:00:00+00:00', + 'precipitation': 0.51, + 'precipitation_probability': 74, + 'pressure': 986.82, + 'temperature': 13.0, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 37.44, + 'wind_speed': 17.46, + }), + dict({ + 'apparent_temperature': 11.2, + 'condition': 'cloudy', + 'datetime': '2024-11-23T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 11, + 'pressure': 986.92, + 'temperature': 13.7, + 'uv_index': 0, + 'wind_bearing': 187, + 'wind_gust_speed': 45.97, + 'wind_speed': 22.72, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'rainy', + 'datetime': '2024-11-23T21:00:00+00:00', + 'precipitation': 0.11, + 'precipitation_probability': 30, + 'pressure': 986.82, + 'temperature': 14.0, + 'uv_index': 0, + 'wind_bearing': 178, + 'wind_gust_speed': 44.32, + 'wind_speed': 22.0, + }), + dict({ + 'apparent_temperature': 11.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 12, + 'pressure': 986.31, + 'temperature': 14.0, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 47.84, + 'wind_speed': 23.65, + }), + dict({ + 'apparent_temperature': 11.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'pressure': 985.71, + 'temperature': 14.3, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 51.44, + 'wind_speed': 26.57, + }), + dict({ + 'apparent_temperature': 11.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'pressure': 984.92, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 50.69, + 'wind_speed': 26.78, + }), + dict({ + 'apparent_temperature': 11.6, + 'condition': 'rainy', + 'datetime': '2024-11-24T01:00:00+00:00', + 'precipitation': 0.17, + 'precipitation_probability': 40, + 'pressure': 984.22, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 50.11, + 'wind_speed': 25.78, + }), + dict({ + 'apparent_temperature': 11.3, + 'condition': 'pouring', + 'datetime': '2024-11-24T02:00:00+00:00', + 'precipitation': 0.21, + 'precipitation_probability': 74, + 'pressure': 983.51, + 'temperature': 14.2, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 52.06, + 'wind_speed': 26.89, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'pouring', + 'datetime': '2024-11-24T03:00:00+00:00', + 'precipitation': 0.34, + 'precipitation_probability': 73, + 'pressure': 983.1, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 187, + 'wind_gust_speed': 51.55, + 'wind_speed': 26.1, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'rainy', + 'datetime': '2024-11-24T04:00:00+00:00', + 'precipitation': 0.28, + 'precipitation_probability': 50, + 'pressure': 983.1, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 49.68, + 'wind_speed': 25.52, + }), + dict({ + 'apparent_temperature': 11.8, + 'condition': 'rainy', + 'datetime': '2024-11-24T05:00:00+00:00', + 'precipitation': 0.25, + 'precipitation_probability': 47, + 'pressure': 983.3, + 'temperature': 14.3, + 'uv_index': 0, + 'wind_bearing': 202, + 'wind_gust_speed': 45.72, + 'wind_speed': 23.69, + }), + dict({ + 'apparent_temperature': 10.7, + 'condition': 'rainy', + 'datetime': '2024-11-24T06:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 42, + 'pressure': 983.96, + 'temperature': 13.4, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 45.83, + 'wind_speed': 24.16, + }), + dict({ + 'apparent_temperature': 10.1, + 'condition': 'rainy', + 'datetime': '2024-11-24T07:00:00+00:00', + 'precipitation': 0.16, + 'precipitation_probability': 40, + 'pressure': 984.58, + 'temperature': 12.5, + 'uv_index': 0, + 'wind_bearing': 214, + 'wind_gust_speed': 39.71, + 'wind_speed': 20.59, + }), + dict({ + 'apparent_temperature': 9.5, + 'condition': 'rainy', + 'datetime': '2024-11-24T08:00:00+00:00', + 'precipitation': 0.08, + 'precipitation_probability': 38, + 'pressure': 985.48, + 'temperature': 11.9, + 'uv_index': 0, + 'wind_bearing': 209, + 'wind_gust_speed': 37.08, + 'wind_speed': 19.73, + }), + dict({ + 'apparent_temperature': 9.1, + 'condition': 'rainy', + 'datetime': '2024-11-24T09:00:00+00:00', + 'precipitation': 0.04, + 'precipitation_probability': 26, + 'pressure': 986.38, + 'temperature': 11.5, + 'uv_index': 1, + 'wind_bearing': 201, + 'wind_gust_speed': 35.96, + 'wind_speed': 19.58, + }), + dict({ + 'apparent_temperature': 9.0, + 'condition': 'rainy', + 'datetime': '2024-11-24T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'pressure': 986.96, + 'temperature': 11.5, + 'uv_index': 1, + 'wind_bearing': 199, + 'wind_gust_speed': 37.01, + 'wind_speed': 20.59, + }), + dict({ + 'apparent_temperature': 9.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 6, + 'pressure': 987.55, + 'temperature': 11.7, + 'uv_index': 1, + 'wind_bearing': 199, + 'wind_gust_speed': 36.22, + 'wind_speed': 20.45, + }), + dict({ + 'apparent_temperature': 9.0, + 'condition': 'cloudy', + 'datetime': '2024-11-24T12:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.48, + 'temperature': 11.8, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 42.66, + 'wind_speed': 23.94, + }), + dict({ + 'apparent_temperature': 8.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T13:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.57, + 'temperature': 11.8, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 45.36, + 'wind_speed': 25.45, + }), + dict({ + 'apparent_temperature': 8.6, + 'condition': 'cloudy', + 'datetime': '2024-11-24T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.37, + 'temperature': 11.7, + 'uv_index': 1, + 'wind_bearing': 201, + 'wind_gust_speed': 46.94, + 'wind_speed': 26.39, + }), + dict({ + 'apparent_temperature': 8.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T15:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 987.27, + 'temperature': 11.6, + 'uv_index': 1, + 'wind_bearing': 198, + 'wind_gust_speed': 46.87, + 'wind_speed': 26.35, + }), + dict({ + 'apparent_temperature': 8.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T16:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.08, + 'temperature': 11.2, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 46.22, + 'wind_speed': 25.38, + }), + dict({ + 'apparent_temperature': 8.0, + 'condition': 'cloudy', + 'datetime': '2024-11-24T17:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 986.89, + 'temperature': 11.0, + 'uv_index': 0, + 'wind_bearing': 194, + 'wind_gust_speed': 45.68, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 7.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.7, + 'temperature': 10.9, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 46.15, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 7.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T19:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.69, + 'temperature': 10.8, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 45.43, + 'wind_speed': 24.95, + }), + dict({ + 'apparent_temperature': 7.7, + 'condition': 'cloudy', + 'datetime': '2024-11-24T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.78, + 'temperature': 10.7, + 'uv_index': 0, + 'wind_bearing': 202, + 'wind_gust_speed': 45.07, + 'wind_speed': 24.55, + }), + dict({ + 'apparent_temperature': 7.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.77, + 'temperature': 10.5, + 'uv_index': 0, + 'wind_bearing': 203, + 'wind_gust_speed': 46.4, + 'wind_speed': 25.49, + }), + dict({ + 'apparent_temperature': 7.3, + 'condition': 'cloudy', + 'datetime': '2024-11-24T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 987.04, + 'temperature': 10.5, + 'uv_index': 0, + 'wind_bearing': 204, + 'wind_gust_speed': 48.24, + 'wind_speed': 26.68, + }), + dict({ + 'apparent_temperature': 7.1, + 'condition': 'cloudy', + 'datetime': '2024-11-24T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.04, + 'temperature': 10.3, + 'uv_index': 0, + 'wind_bearing': 207, + 'wind_gust_speed': 50.44, + 'wind_speed': 27.72, + }), + dict({ + 'apparent_temperature': 7.1, + 'condition': 'cloudy', + 'datetime': '2024-11-25T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 23, + 'pressure': 987.12, + 'temperature': 10.2, + 'uv_index': 0, + 'wind_bearing': 211, + 'wind_gust_speed': 47.2, + 'wind_speed': 26.39, + }), + dict({ + 'apparent_temperature': 6.9, + 'condition': 'cloudy', + 'datetime': '2024-11-25T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 11, + 'pressure': 987.41, + 'temperature': 10.0, + 'uv_index': 0, + 'wind_bearing': 215, + 'wind_gust_speed': 45.04, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 6.4, + 'condition': 'cloudy', + 'datetime': '2024-11-25T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 7, + 'pressure': 987.88, + 'temperature': 9.6, + 'uv_index': 0, + 'wind_bearing': 222, + 'wind_gust_speed': 46.87, + 'wind_speed': 25.7, + }), + dict({ + 'apparent_temperature': 6.1, + 'condition': 'cloudy', + 'datetime': '2024-11-25T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 988.16, + 'temperature': 9.3, + 'uv_index': 0, + 'wind_bearing': 226, + 'wind_gust_speed': 44.71, + 'wind_speed': 24.88, + }), + dict({ + 'apparent_temperature': 5.8, + 'condition': 'cloudy', + 'datetime': '2024-11-25T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 988.58, + 'temperature': 9.1, + 'uv_index': 0, + 'wind_bearing': 228, + 'wind_gust_speed': 45.22, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 5.4, + 'condition': 'clear-night', + 'datetime': '2024-11-25T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 989.1, + 'temperature': 8.8, + 'uv_index': 0, + 'wind_bearing': 232, + 'wind_gust_speed': 47.23, + 'wind_speed': 26.14, + }), + dict({ + 'apparent_temperature': 5.1, + 'condition': 'clear-night', + 'datetime': '2024-11-25T06:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 989.61, + 'temperature': 8.7, + 'uv_index': 0, + 'wind_bearing': 235, + 'wind_gust_speed': 48.2, + 'wind_speed': 26.35, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'clear-night', + 'datetime': '2024-11-25T07:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 990.61, + 'temperature': 8.6, + 'uv_index': 0, + 'wind_bearing': 240, + 'wind_gust_speed': 47.81, + 'wind_speed': 26.78, + }), + dict({ + 'apparent_temperature': 4.8, + 'condition': 'clear-night', + 'datetime': '2024-11-25T08:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 991.61, + 'temperature': 8.4, + 'uv_index': 0, + 'wind_bearing': 243, + 'wind_gust_speed': 47.56, + 'wind_speed': 26.86, + }), + dict({ + 'apparent_temperature': 4.8, + 'condition': 'sunny', + 'datetime': '2024-11-25T09:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 992.52, + 'temperature': 8.4, + 'uv_index': 1, + 'wind_bearing': 243, + 'wind_gust_speed': 47.84, + 'wind_speed': 27.32, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 993.42, + 'temperature': 8.7, + 'uv_index': 1, + 'wind_bearing': 245, + 'wind_gust_speed': 49.79, + 'wind_speed': 28.8, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 3, + 'pressure': 994.24, + 'temperature': 8.8, + 'uv_index': 1, + 'wind_bearing': 249, + 'wind_gust_speed': 52.09, + 'wind_speed': 30.38, + }), + dict({ + 'apparent_temperature': 5.2, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T12:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 2, + 'pressure': 994.88, + 'temperature': 8.9, + 'uv_index': 1, + 'wind_bearing': 251, + 'wind_gust_speed': 52.16, + 'wind_speed': 30.67, + }), ]), }), }) # --- -# name: test_forecast_subscription[daily] - list([ - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T12:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 13.0, - 'wind_bearing': 'WNW', - 'wind_speed': 14.48, +# name: test_forecast_service[get_forecasts].5 + dict({ + 'weather.met_office_wavertree': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 4.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 23, + 'pressure': 987.12, + 'temperature': 10.7, + 'templow': 7.0, + 'uv_index': None, + 'wind_bearing': 211, + 'wind_gust_speed': 47.2, + 'wind_speed': 26.39, + }), + dict({ + 'apparent_temperature': 9.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 26, + 'pressure': 987.48, + 'temperature': 15.2, + 'templow': 11.9, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 42.66, + 'wind_speed': 23.94, + }), + dict({ + 'apparent_temperature': 4.2, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 6, + 'pressure': 1004.81, + 'temperature': 9.3, + 'templow': 4.4, + 'uv_index': None, + 'wind_bearing': 262, + 'wind_gust_speed': 47.99, + 'wind_speed': 29.23, + }), + dict({ + 'apparent_temperature': 5.3, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 5, + 'pressure': 994.88, + 'temperature': 11.0, + 'templow': 8.4, + 'uv_index': 1, + 'wind_bearing': 251, + 'wind_gust_speed': 52.16, + 'wind_speed': 30.67, + }), + dict({ + 'apparent_temperature': 1.3, + 'condition': 'cloudy', + 'datetime': '2024-11-26T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 44, + 'pressure': 1013.9, + 'temperature': 7.5, + 'templow': -0.4, + 'uv_index': None, + 'wind_bearing': 74, + 'wind_gust_speed': 19.51, + 'wind_speed': 11.41, + }), + dict({ + 'apparent_temperature': 5.9, + 'condition': 'partlycloudy', + 'datetime': '2024-11-26T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 6, + 'pressure': 1012.93, + 'temperature': 10.1, + 'templow': 6.5, + 'uv_index': 1, + 'wind_bearing': 265, + 'wind_gust_speed': 34.49, + 'wind_speed': 20.45, + }), + dict({ + 'apparent_temperature': 0.2, + 'condition': 'clear-night', + 'datetime': '2024-11-27T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 9, + 'pressure': 1021.75, + 'temperature': 7.2, + 'templow': -3.0, + 'uv_index': None, + 'wind_bearing': 31, + 'wind_gust_speed': 19.94, + 'wind_speed': 11.84, + }), + dict({ + 'apparent_temperature': 3.3, + 'condition': 'rainy', + 'datetime': '2024-11-27T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 43, + 'pressure': 1014.39, + 'temperature': 11.1, + 'templow': 3.0, + 'uv_index': 1, + 'wind_bearing': 8, + 'wind_gust_speed': 32.18, + 'wind_speed': 18.54, + }), + dict({ + 'apparent_temperature': 1.6, + 'condition': 'cloudy', + 'datetime': '2024-11-28T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 9, + 'pressure': 1023.82, + 'temperature': 8.2, + 'templow': -1.9, + 'uv_index': None, + 'wind_bearing': 131, + 'wind_gust_speed': 33.16, + 'wind_speed': 20.05, + }), + dict({ + 'apparent_temperature': 3.0, + 'condition': 'cloudy', + 'datetime': '2024-11-28T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 9, + 'pressure': 1025.12, + 'temperature': 9.4, + 'templow': 1.3, + 'uv_index': 1, + 'wind_bearing': 104, + 'wind_gust_speed': 22.36, + 'wind_speed': 12.64, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'cloudy', + 'datetime': '2024-11-29T00:00:00+00:00', + 'is_daytime': False, + 'precipitation': None, + 'precipitation_probability': 13, + 'pressure': 1016.88, + 'temperature': 10.8, + 'templow': -1.9, + 'uv_index': None, + 'wind_bearing': 151, + 'wind_gust_speed': 33.16, + 'wind_speed': 20.12, + }), + dict({ + 'apparent_temperature': 4.9, + 'condition': 'cloudy', + 'datetime': '2024-11-29T12:00:00+00:00', + 'is_daytime': True, + 'precipitation': None, + 'precipitation_probability': 11, + 'pressure': 1019.85, + 'temperature': 12.6, + 'templow': 4.2, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 38.59, + 'wind_speed': 23.0, + }), + ]), }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T12:00:00+00:00', - 'precipitation_probability': 14, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T12:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 12.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2020-04-29T12:00:00+00:00', - 'precipitation_probability': 59, - 'temperature': 13.0, - 'wind_bearing': 'SE', - 'wind_speed': 20.92, - }), - ]) + }) # --- -# name: test_forecast_subscription[daily].1 +# name: test_forecast_subscription list([ dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T12:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 13.0, - 'wind_bearing': 'WNW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T12:00:00+00:00', - 'precipitation_probability': 14, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T12:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 12.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ + 'apparent_temperature': 6.8, 'condition': 'rainy', - 'datetime': '2020-04-29T12:00:00+00:00', - 'precipitation_probability': 59, - 'temperature': 13.0, - 'wind_bearing': 'SE', - 'wind_speed': 20.92, - }), - ]) -# --- -# name: test_forecast_subscription[hourly] - list([ - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-25T15:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 19.0, - 'wind_bearing': 'S', - 'wind_speed': 6.44, + 'datetime': '2024-11-23T13:00:00+00:00', + 'precipitation': 0.52, + 'precipitation_probability': 65, + 'pressure': 986.83, + 'temperature': 9.9, + 'uv_index': 1, + 'wind_bearing': 178, + 'wind_gust_speed': 55.73, + 'wind_speed': 25.42, }), dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-25T18:00:00+00:00', - 'precipitation_probability': 2, - 'temperature': 17.0, - 'wind_bearing': 'WNW', - 'wind_speed': 11.27, + 'apparent_temperature': 8.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 12, + 'pressure': 986.34, + 'temperature': 11.1, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 49.0, + 'wind_speed': 22.86, }), dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-25T21:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 14.0, - 'wind_bearing': 'NW', - 'wind_speed': 3.22, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-26T00:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 13.0, - 'wind_bearing': 'WSW', - 'wind_speed': 3.22, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-26T03:00:00+00:00', - 'precipitation_probability': 2, + 'apparent_temperature': 9.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T15:00:00+00:00', + 'precipitation': 0.09, + 'precipitation_probability': 37, + 'pressure': 986.13, 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, + 'uv_index': 1, + 'wind_bearing': 182, + 'wind_gust_speed': 40.1, + 'wind_speed': 18.5, }), dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T06:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, + 'apparent_temperature': 10.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T16:00:00+00:00', + 'precipitation': 0.27, + 'precipitation_probability': 36, + 'pressure': 986.6, + 'temperature': 12.6, + 'uv_index': 0, + 'wind_bearing': 197, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.44, }), dict({ + 'apparent_temperature': 11.3, 'condition': 'cloudy', - 'datetime': '2020-04-26T09:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T12:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T15:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 12.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T18:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T21:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-27T00:00:00+00:00', + 'datetime': '2024-11-23T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 11, - 'temperature': 9.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, + 'pressure': 987.1, + 'temperature': 12.9, + 'uv_index': 0, + 'wind_bearing': 203, + 'wind_gust_speed': 35.57, + 'wind_speed': 15.59, }), dict({ + 'apparent_temperature': 11.3, 'condition': 'cloudy', - 'datetime': '2020-04-27T03:00:00+00:00', - 'precipitation_probability': 12, - 'temperature': 8.0, - 'wind_bearing': 'WNW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-27T06:00:00+00:00', - 'precipitation_probability': 14, - 'temperature': 8.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T09:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T12:00:00+00:00', - 'precipitation_probability': 4, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-27T15:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-27T18:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-27T21:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 9.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-28T00:00:00+00:00', - 'precipitation_probability': 2, - 'temperature': 8.0, - 'wind_bearing': 'NNW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-28T03:00:00+00:00', - 'precipitation_probability': 3, - 'temperature': 7.0, - 'wind_bearing': 'W', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-28T06:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 6.0, - 'wind_bearing': 'S', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-28T09:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T12:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 11.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T15:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 12.0, - 'wind_bearing': 'N', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T18:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 11.0, - 'wind_bearing': 'N', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T21:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 10.0, - 'wind_bearing': 'NNE', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T00:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'E', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-29T03:00:00+00:00', - 'precipitation_probability': 3, - 'temperature': 8.0, - 'wind_bearing': 'SSE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T06:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 8.0, - 'wind_bearing': 'SE', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T09:00:00+00:00', - 'precipitation_probability': 12, - 'temperature': 10.0, - 'wind_bearing': 'SE', - 'wind_speed': 17.7, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2020-04-29T12:00:00+00:00', - 'precipitation_probability': 47, - 'temperature': 12.0, - 'wind_bearing': 'SE', - 'wind_speed': 20.92, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2020-04-29T15:00:00+00:00', - 'precipitation_probability': 59, + 'datetime': '2024-11-23T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 7, + 'pressure': 987.1, 'temperature': 13.0, - 'wind_bearing': 'SSE', - 'wind_speed': 20.92, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 31.21, + 'wind_speed': 15.52, }), dict({ - 'condition': 'rainy', - 'datetime': '2020-04-29T18:00:00+00:00', - 'precipitation_probability': 39, - 'temperature': 12.0, - 'wind_bearing': 'SSE', - 'wind_speed': 17.7, + 'apparent_temperature': 11.1, + 'condition': 'pouring', + 'datetime': '2024-11-23T19:00:00+00:00', + 'precipitation': 0.51, + 'precipitation_probability': 74, + 'pressure': 986.82, + 'temperature': 13.0, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 37.44, + 'wind_speed': 17.46, }), dict({ + 'apparent_temperature': 11.2, 'condition': 'cloudy', - 'datetime': '2020-04-29T21:00:00+00:00', - 'precipitation_probability': 19, + 'datetime': '2024-11-23T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 11, + 'pressure': 986.92, + 'temperature': 13.7, + 'uv_index': 0, + 'wind_bearing': 187, + 'wind_gust_speed': 45.97, + 'wind_speed': 22.72, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'rainy', + 'datetime': '2024-11-23T21:00:00+00:00', + 'precipitation': 0.11, + 'precipitation_probability': 30, + 'pressure': 986.82, + 'temperature': 14.0, + 'uv_index': 0, + 'wind_bearing': 178, + 'wind_gust_speed': 44.32, + 'wind_speed': 22.0, + }), + dict({ + 'apparent_temperature': 11.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 12, + 'pressure': 986.31, + 'temperature': 14.0, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 47.84, + 'wind_speed': 23.65, + }), + dict({ + 'apparent_temperature': 11.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'pressure': 985.71, + 'temperature': 14.3, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 51.44, + 'wind_speed': 26.57, + }), + dict({ + 'apparent_temperature': 11.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'pressure': 984.92, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 50.69, + 'wind_speed': 26.78, + }), + dict({ + 'apparent_temperature': 11.6, + 'condition': 'rainy', + 'datetime': '2024-11-24T01:00:00+00:00', + 'precipitation': 0.17, + 'precipitation_probability': 40, + 'pressure': 984.22, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 50.11, + 'wind_speed': 25.78, + }), + dict({ + 'apparent_temperature': 11.3, + 'condition': 'pouring', + 'datetime': '2024-11-24T02:00:00+00:00', + 'precipitation': 0.21, + 'precipitation_probability': 74, + 'pressure': 983.51, + 'temperature': 14.2, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 52.06, + 'wind_speed': 26.89, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'pouring', + 'datetime': '2024-11-24T03:00:00+00:00', + 'precipitation': 0.34, + 'precipitation_probability': 73, + 'pressure': 983.1, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 187, + 'wind_gust_speed': 51.55, + 'wind_speed': 26.1, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'rainy', + 'datetime': '2024-11-24T04:00:00+00:00', + 'precipitation': 0.28, + 'precipitation_probability': 50, + 'pressure': 983.1, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 49.68, + 'wind_speed': 25.52, + }), + dict({ + 'apparent_temperature': 11.8, + 'condition': 'rainy', + 'datetime': '2024-11-24T05:00:00+00:00', + 'precipitation': 0.25, + 'precipitation_probability': 47, + 'pressure': 983.3, + 'temperature': 14.3, + 'uv_index': 0, + 'wind_bearing': 202, + 'wind_gust_speed': 45.72, + 'wind_speed': 23.69, + }), + dict({ + 'apparent_temperature': 10.7, + 'condition': 'rainy', + 'datetime': '2024-11-24T06:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 42, + 'pressure': 983.96, + 'temperature': 13.4, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 45.83, + 'wind_speed': 24.16, + }), + dict({ + 'apparent_temperature': 10.1, + 'condition': 'rainy', + 'datetime': '2024-11-24T07:00:00+00:00', + 'precipitation': 0.16, + 'precipitation_probability': 40, + 'pressure': 984.58, + 'temperature': 12.5, + 'uv_index': 0, + 'wind_bearing': 214, + 'wind_gust_speed': 39.71, + 'wind_speed': 20.59, + }), + dict({ + 'apparent_temperature': 9.5, + 'condition': 'rainy', + 'datetime': '2024-11-24T08:00:00+00:00', + 'precipitation': 0.08, + 'precipitation_probability': 38, + 'pressure': 985.48, + 'temperature': 11.9, + 'uv_index': 0, + 'wind_bearing': 209, + 'wind_gust_speed': 37.08, + 'wind_speed': 19.73, + }), + dict({ + 'apparent_temperature': 9.1, + 'condition': 'rainy', + 'datetime': '2024-11-24T09:00:00+00:00', + 'precipitation': 0.04, + 'precipitation_probability': 26, + 'pressure': 986.38, + 'temperature': 11.5, + 'uv_index': 1, + 'wind_bearing': 201, + 'wind_gust_speed': 35.96, + 'wind_speed': 19.58, + }), + dict({ + 'apparent_temperature': 9.0, + 'condition': 'rainy', + 'datetime': '2024-11-24T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'pressure': 986.96, + 'temperature': 11.5, + 'uv_index': 1, + 'wind_bearing': 199, + 'wind_gust_speed': 37.01, + 'wind_speed': 20.59, + }), + dict({ + 'apparent_temperature': 9.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 6, + 'pressure': 987.55, + 'temperature': 11.7, + 'uv_index': 1, + 'wind_bearing': 199, + 'wind_gust_speed': 36.22, + 'wind_speed': 20.45, + }), + dict({ + 'apparent_temperature': 9.0, + 'condition': 'cloudy', + 'datetime': '2024-11-24T12:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.48, + 'temperature': 11.8, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 42.66, + 'wind_speed': 23.94, + }), + dict({ + 'apparent_temperature': 8.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T13:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.57, + 'temperature': 11.8, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 45.36, + 'wind_speed': 25.45, + }), + dict({ + 'apparent_temperature': 8.6, + 'condition': 'cloudy', + 'datetime': '2024-11-24T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.37, + 'temperature': 11.7, + 'uv_index': 1, + 'wind_bearing': 201, + 'wind_gust_speed': 46.94, + 'wind_speed': 26.39, + }), + dict({ + 'apparent_temperature': 8.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T15:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 987.27, + 'temperature': 11.6, + 'uv_index': 1, + 'wind_bearing': 198, + 'wind_gust_speed': 46.87, + 'wind_speed': 26.35, + }), + dict({ + 'apparent_temperature': 8.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T16:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.08, + 'temperature': 11.2, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 46.22, + 'wind_speed': 25.38, + }), + dict({ + 'apparent_temperature': 8.0, + 'condition': 'cloudy', + 'datetime': '2024-11-24T17:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 986.89, 'temperature': 11.0, - 'wind_bearing': 'SSE', - 'wind_speed': 20.92, + 'uv_index': 0, + 'wind_bearing': 194, + 'wind_gust_speed': 45.68, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 7.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.7, + 'temperature': 10.9, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 46.15, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 7.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T19:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.69, + 'temperature': 10.8, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 45.43, + 'wind_speed': 24.95, + }), + dict({ + 'apparent_temperature': 7.7, + 'condition': 'cloudy', + 'datetime': '2024-11-24T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.78, + 'temperature': 10.7, + 'uv_index': 0, + 'wind_bearing': 202, + 'wind_gust_speed': 45.07, + 'wind_speed': 24.55, + }), + dict({ + 'apparent_temperature': 7.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.77, + 'temperature': 10.5, + 'uv_index': 0, + 'wind_bearing': 203, + 'wind_gust_speed': 46.4, + 'wind_speed': 25.49, + }), + dict({ + 'apparent_temperature': 7.3, + 'condition': 'cloudy', + 'datetime': '2024-11-24T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 987.04, + 'temperature': 10.5, + 'uv_index': 0, + 'wind_bearing': 204, + 'wind_gust_speed': 48.24, + 'wind_speed': 26.68, + }), + dict({ + 'apparent_temperature': 7.1, + 'condition': 'cloudy', + 'datetime': '2024-11-24T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.04, + 'temperature': 10.3, + 'uv_index': 0, + 'wind_bearing': 207, + 'wind_gust_speed': 50.44, + 'wind_speed': 27.72, + }), + dict({ + 'apparent_temperature': 7.1, + 'condition': 'cloudy', + 'datetime': '2024-11-25T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 23, + 'pressure': 987.12, + 'temperature': 10.2, + 'uv_index': 0, + 'wind_bearing': 211, + 'wind_gust_speed': 47.2, + 'wind_speed': 26.39, + }), + dict({ + 'apparent_temperature': 6.9, + 'condition': 'cloudy', + 'datetime': '2024-11-25T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 11, + 'pressure': 987.41, + 'temperature': 10.0, + 'uv_index': 0, + 'wind_bearing': 215, + 'wind_gust_speed': 45.04, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 6.4, + 'condition': 'cloudy', + 'datetime': '2024-11-25T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 7, + 'pressure': 987.88, + 'temperature': 9.6, + 'uv_index': 0, + 'wind_bearing': 222, + 'wind_gust_speed': 46.87, + 'wind_speed': 25.7, + }), + dict({ + 'apparent_temperature': 6.1, + 'condition': 'cloudy', + 'datetime': '2024-11-25T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 988.16, + 'temperature': 9.3, + 'uv_index': 0, + 'wind_bearing': 226, + 'wind_gust_speed': 44.71, + 'wind_speed': 24.88, + }), + dict({ + 'apparent_temperature': 5.8, + 'condition': 'cloudy', + 'datetime': '2024-11-25T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 988.58, + 'temperature': 9.1, + 'uv_index': 0, + 'wind_bearing': 228, + 'wind_gust_speed': 45.22, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 5.4, + 'condition': 'clear-night', + 'datetime': '2024-11-25T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 989.1, + 'temperature': 8.8, + 'uv_index': 0, + 'wind_bearing': 232, + 'wind_gust_speed': 47.23, + 'wind_speed': 26.14, + }), + dict({ + 'apparent_temperature': 5.1, + 'condition': 'clear-night', + 'datetime': '2024-11-25T06:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 989.61, + 'temperature': 8.7, + 'uv_index': 0, + 'wind_bearing': 235, + 'wind_gust_speed': 48.2, + 'wind_speed': 26.35, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'clear-night', + 'datetime': '2024-11-25T07:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 990.61, + 'temperature': 8.6, + 'uv_index': 0, + 'wind_bearing': 240, + 'wind_gust_speed': 47.81, + 'wind_speed': 26.78, + }), + dict({ + 'apparent_temperature': 4.8, + 'condition': 'clear-night', + 'datetime': '2024-11-25T08:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 991.61, + 'temperature': 8.4, + 'uv_index': 0, + 'wind_bearing': 243, + 'wind_gust_speed': 47.56, + 'wind_speed': 26.86, + }), + dict({ + 'apparent_temperature': 4.8, + 'condition': 'sunny', + 'datetime': '2024-11-25T09:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 992.52, + 'temperature': 8.4, + 'uv_index': 1, + 'wind_bearing': 243, + 'wind_gust_speed': 47.84, + 'wind_speed': 27.32, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 993.42, + 'temperature': 8.7, + 'uv_index': 1, + 'wind_bearing': 245, + 'wind_gust_speed': 49.79, + 'wind_speed': 28.8, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 3, + 'pressure': 994.24, + 'temperature': 8.8, + 'uv_index': 1, + 'wind_bearing': 249, + 'wind_gust_speed': 52.09, + 'wind_speed': 30.38, + }), + dict({ + 'apparent_temperature': 5.2, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T12:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 2, + 'pressure': 994.88, + 'temperature': 8.9, + 'uv_index': 1, + 'wind_bearing': 251, + 'wind_gust_speed': 52.16, + 'wind_speed': 30.67, }), ]) # --- -# name: test_forecast_subscription[hourly].1 +# name: test_forecast_subscription.1 list([ dict({ - 'condition': 'sunny', - 'datetime': '2020-04-25T15:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 19.0, - 'wind_bearing': 'S', - 'wind_speed': 6.44, + 'apparent_temperature': 6.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T13:00:00+00:00', + 'precipitation': 0.52, + 'precipitation_probability': 65, + 'pressure': 986.83, + 'temperature': 9.9, + 'uv_index': 1, + 'wind_bearing': 178, + 'wind_gust_speed': 55.73, + 'wind_speed': 25.42, }), dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-25T18:00:00+00:00', - 'precipitation_probability': 2, - 'temperature': 17.0, - 'wind_bearing': 'WNW', - 'wind_speed': 11.27, + 'apparent_temperature': 8.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 12, + 'pressure': 986.34, + 'temperature': 11.1, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 49.0, + 'wind_speed': 22.86, }), dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-25T21:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 14.0, - 'wind_bearing': 'NW', - 'wind_speed': 3.22, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-26T00:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 13.0, - 'wind_bearing': 'WSW', - 'wind_speed': 3.22, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-26T03:00:00+00:00', - 'precipitation_probability': 2, + 'apparent_temperature': 9.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T15:00:00+00:00', + 'precipitation': 0.09, + 'precipitation_probability': 37, + 'pressure': 986.13, 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, + 'uv_index': 1, + 'wind_bearing': 182, + 'wind_gust_speed': 40.1, + 'wind_speed': 18.5, }), dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T06:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, + 'apparent_temperature': 10.8, + 'condition': 'rainy', + 'datetime': '2024-11-23T16:00:00+00:00', + 'precipitation': 0.27, + 'precipitation_probability': 36, + 'pressure': 986.6, + 'temperature': 12.6, + 'uv_index': 0, + 'wind_bearing': 197, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.44, }), dict({ + 'apparent_temperature': 11.3, 'condition': 'cloudy', - 'datetime': '2020-04-26T09:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T12:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 12.0, - 'wind_bearing': 'WNW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T15:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 12.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T18:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 11.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-26T21:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-27T00:00:00+00:00', + 'datetime': '2024-11-23T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 11, - 'temperature': 9.0, - 'wind_bearing': 'WNW', - 'wind_speed': 6.44, + 'pressure': 987.1, + 'temperature': 12.9, + 'uv_index': 0, + 'wind_bearing': 203, + 'wind_gust_speed': 35.57, + 'wind_speed': 15.59, }), dict({ + 'apparent_temperature': 11.3, 'condition': 'cloudy', - 'datetime': '2020-04-27T03:00:00+00:00', - 'precipitation_probability': 12, - 'temperature': 8.0, - 'wind_bearing': 'WNW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-27T06:00:00+00:00', - 'precipitation_probability': 14, - 'temperature': 8.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T09:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-27T12:00:00+00:00', - 'precipitation_probability': 4, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-27T15:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-27T18:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 10.0, - 'wind_bearing': 'NW', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-27T21:00:00+00:00', - 'precipitation_probability': 1, - 'temperature': 9.0, - 'wind_bearing': 'NW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-28T00:00:00+00:00', - 'precipitation_probability': 2, - 'temperature': 8.0, - 'wind_bearing': 'NNW', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2020-04-28T03:00:00+00:00', - 'precipitation_probability': 3, - 'temperature': 7.0, - 'wind_bearing': 'W', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2020-04-28T06:00:00+00:00', - 'precipitation_probability': 5, - 'temperature': 6.0, - 'wind_bearing': 'S', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-28T09:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T12:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 11.0, - 'wind_bearing': 'ENE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T15:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 12.0, - 'wind_bearing': 'N', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T18:00:00+00:00', - 'precipitation_probability': 10, - 'temperature': 11.0, - 'wind_bearing': 'N', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-28T21:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 10.0, - 'wind_bearing': 'NNE', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T00:00:00+00:00', - 'precipitation_probability': 6, - 'temperature': 9.0, - 'wind_bearing': 'E', - 'wind_speed': 6.44, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2020-04-29T03:00:00+00:00', - 'precipitation_probability': 3, - 'temperature': 8.0, - 'wind_bearing': 'SSE', - 'wind_speed': 11.27, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T06:00:00+00:00', - 'precipitation_probability': 9, - 'temperature': 8.0, - 'wind_bearing': 'SE', - 'wind_speed': 14.48, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2020-04-29T09:00:00+00:00', - 'precipitation_probability': 12, - 'temperature': 10.0, - 'wind_bearing': 'SE', - 'wind_speed': 17.7, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2020-04-29T12:00:00+00:00', - 'precipitation_probability': 47, - 'temperature': 12.0, - 'wind_bearing': 'SE', - 'wind_speed': 20.92, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2020-04-29T15:00:00+00:00', - 'precipitation_probability': 59, + 'datetime': '2024-11-23T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 7, + 'pressure': 987.1, 'temperature': 13.0, - 'wind_bearing': 'SSE', - 'wind_speed': 20.92, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 31.21, + 'wind_speed': 15.52, }), dict({ - 'condition': 'rainy', - 'datetime': '2020-04-29T18:00:00+00:00', - 'precipitation_probability': 39, - 'temperature': 12.0, - 'wind_bearing': 'SSE', - 'wind_speed': 17.7, + 'apparent_temperature': 11.1, + 'condition': 'pouring', + 'datetime': '2024-11-23T19:00:00+00:00', + 'precipitation': 0.51, + 'precipitation_probability': 74, + 'pressure': 986.82, + 'temperature': 13.0, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 37.44, + 'wind_speed': 17.46, }), dict({ + 'apparent_temperature': 11.2, 'condition': 'cloudy', - 'datetime': '2020-04-29T21:00:00+00:00', - 'precipitation_probability': 19, + 'datetime': '2024-11-23T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 11, + 'pressure': 986.92, + 'temperature': 13.7, + 'uv_index': 0, + 'wind_bearing': 187, + 'wind_gust_speed': 45.97, + 'wind_speed': 22.72, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'rainy', + 'datetime': '2024-11-23T21:00:00+00:00', + 'precipitation': 0.11, + 'precipitation_probability': 30, + 'pressure': 986.82, + 'temperature': 14.0, + 'uv_index': 0, + 'wind_bearing': 178, + 'wind_gust_speed': 44.32, + 'wind_speed': 22.0, + }), + dict({ + 'apparent_temperature': 11.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 12, + 'pressure': 986.31, + 'temperature': 14.0, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 47.84, + 'wind_speed': 23.65, + }), + dict({ + 'apparent_temperature': 11.4, + 'condition': 'cloudy', + 'datetime': '2024-11-23T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'pressure': 985.71, + 'temperature': 14.3, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 51.44, + 'wind_speed': 26.57, + }), + dict({ + 'apparent_temperature': 11.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'pressure': 984.92, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 50.69, + 'wind_speed': 26.78, + }), + dict({ + 'apparent_temperature': 11.6, + 'condition': 'rainy', + 'datetime': '2024-11-24T01:00:00+00:00', + 'precipitation': 0.17, + 'precipitation_probability': 40, + 'pressure': 984.22, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 50.11, + 'wind_speed': 25.78, + }), + dict({ + 'apparent_temperature': 11.3, + 'condition': 'pouring', + 'datetime': '2024-11-24T02:00:00+00:00', + 'precipitation': 0.21, + 'precipitation_probability': 74, + 'pressure': 983.51, + 'temperature': 14.2, + 'uv_index': 0, + 'wind_bearing': 176, + 'wind_gust_speed': 52.06, + 'wind_speed': 26.89, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'pouring', + 'datetime': '2024-11-24T03:00:00+00:00', + 'precipitation': 0.34, + 'precipitation_probability': 73, + 'pressure': 983.1, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 187, + 'wind_gust_speed': 51.55, + 'wind_speed': 26.1, + }), + dict({ + 'apparent_temperature': 11.7, + 'condition': 'rainy', + 'datetime': '2024-11-24T04:00:00+00:00', + 'precipitation': 0.28, + 'precipitation_probability': 50, + 'pressure': 983.1, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 49.68, + 'wind_speed': 25.52, + }), + dict({ + 'apparent_temperature': 11.8, + 'condition': 'rainy', + 'datetime': '2024-11-24T05:00:00+00:00', + 'precipitation': 0.25, + 'precipitation_probability': 47, + 'pressure': 983.3, + 'temperature': 14.3, + 'uv_index': 0, + 'wind_bearing': 202, + 'wind_gust_speed': 45.72, + 'wind_speed': 23.69, + }), + dict({ + 'apparent_temperature': 10.7, + 'condition': 'rainy', + 'datetime': '2024-11-24T06:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 42, + 'pressure': 983.96, + 'temperature': 13.4, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 45.83, + 'wind_speed': 24.16, + }), + dict({ + 'apparent_temperature': 10.1, + 'condition': 'rainy', + 'datetime': '2024-11-24T07:00:00+00:00', + 'precipitation': 0.16, + 'precipitation_probability': 40, + 'pressure': 984.58, + 'temperature': 12.5, + 'uv_index': 0, + 'wind_bearing': 214, + 'wind_gust_speed': 39.71, + 'wind_speed': 20.59, + }), + dict({ + 'apparent_temperature': 9.5, + 'condition': 'rainy', + 'datetime': '2024-11-24T08:00:00+00:00', + 'precipitation': 0.08, + 'precipitation_probability': 38, + 'pressure': 985.48, + 'temperature': 11.9, + 'uv_index': 0, + 'wind_bearing': 209, + 'wind_gust_speed': 37.08, + 'wind_speed': 19.73, + }), + dict({ + 'apparent_temperature': 9.1, + 'condition': 'rainy', + 'datetime': '2024-11-24T09:00:00+00:00', + 'precipitation': 0.04, + 'precipitation_probability': 26, + 'pressure': 986.38, + 'temperature': 11.5, + 'uv_index': 1, + 'wind_bearing': 201, + 'wind_gust_speed': 35.96, + 'wind_speed': 19.58, + }), + dict({ + 'apparent_temperature': 9.0, + 'condition': 'rainy', + 'datetime': '2024-11-24T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'pressure': 986.96, + 'temperature': 11.5, + 'uv_index': 1, + 'wind_bearing': 199, + 'wind_gust_speed': 37.01, + 'wind_speed': 20.59, + }), + dict({ + 'apparent_temperature': 9.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 6, + 'pressure': 987.55, + 'temperature': 11.7, + 'uv_index': 1, + 'wind_bearing': 199, + 'wind_gust_speed': 36.22, + 'wind_speed': 20.45, + }), + dict({ + 'apparent_temperature': 9.0, + 'condition': 'cloudy', + 'datetime': '2024-11-24T12:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.48, + 'temperature': 11.8, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 42.66, + 'wind_speed': 23.94, + }), + dict({ + 'apparent_temperature': 8.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T13:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.57, + 'temperature': 11.8, + 'uv_index': 1, + 'wind_bearing': 203, + 'wind_gust_speed': 45.36, + 'wind_speed': 25.45, + }), + dict({ + 'apparent_temperature': 8.6, + 'condition': 'cloudy', + 'datetime': '2024-11-24T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.37, + 'temperature': 11.7, + 'uv_index': 1, + 'wind_bearing': 201, + 'wind_gust_speed': 46.94, + 'wind_speed': 26.39, + }), + dict({ + 'apparent_temperature': 8.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T15:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 987.27, + 'temperature': 11.6, + 'uv_index': 1, + 'wind_bearing': 198, + 'wind_gust_speed': 46.87, + 'wind_speed': 26.35, + }), + dict({ + 'apparent_temperature': 8.2, + 'condition': 'cloudy', + 'datetime': '2024-11-24T16:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.08, + 'temperature': 11.2, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 46.22, + 'wind_speed': 25.38, + }), + dict({ + 'apparent_temperature': 8.0, + 'condition': 'cloudy', + 'datetime': '2024-11-24T17:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 986.89, 'temperature': 11.0, - 'wind_bearing': 'SSE', - 'wind_speed': 20.92, + 'uv_index': 0, + 'wind_bearing': 194, + 'wind_gust_speed': 45.68, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 7.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.7, + 'temperature': 10.9, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 46.15, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 7.8, + 'condition': 'cloudy', + 'datetime': '2024-11-24T19:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.69, + 'temperature': 10.8, + 'uv_index': 0, + 'wind_bearing': 196, + 'wind_gust_speed': 45.43, + 'wind_speed': 24.95, + }), + dict({ + 'apparent_temperature': 7.7, + 'condition': 'cloudy', + 'datetime': '2024-11-24T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.78, + 'temperature': 10.7, + 'uv_index': 0, + 'wind_bearing': 202, + 'wind_gust_speed': 45.07, + 'wind_speed': 24.55, + }), + dict({ + 'apparent_temperature': 7.5, + 'condition': 'cloudy', + 'datetime': '2024-11-24T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 986.77, + 'temperature': 10.5, + 'uv_index': 0, + 'wind_bearing': 203, + 'wind_gust_speed': 46.4, + 'wind_speed': 25.49, + }), + dict({ + 'apparent_temperature': 7.3, + 'condition': 'cloudy', + 'datetime': '2024-11-24T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'pressure': 987.04, + 'temperature': 10.5, + 'uv_index': 0, + 'wind_bearing': 204, + 'wind_gust_speed': 48.24, + 'wind_speed': 26.68, + }), + dict({ + 'apparent_temperature': 7.1, + 'condition': 'cloudy', + 'datetime': '2024-11-24T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 987.04, + 'temperature': 10.3, + 'uv_index': 0, + 'wind_bearing': 207, + 'wind_gust_speed': 50.44, + 'wind_speed': 27.72, + }), + dict({ + 'apparent_temperature': 7.1, + 'condition': 'cloudy', + 'datetime': '2024-11-25T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 23, + 'pressure': 987.12, + 'temperature': 10.2, + 'uv_index': 0, + 'wind_bearing': 211, + 'wind_gust_speed': 47.2, + 'wind_speed': 26.39, + }), + dict({ + 'apparent_temperature': 6.9, + 'condition': 'cloudy', + 'datetime': '2024-11-25T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 11, + 'pressure': 987.41, + 'temperature': 10.0, + 'uv_index': 0, + 'wind_bearing': 215, + 'wind_gust_speed': 45.04, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 6.4, + 'condition': 'cloudy', + 'datetime': '2024-11-25T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 7, + 'pressure': 987.88, + 'temperature': 9.6, + 'uv_index': 0, + 'wind_bearing': 222, + 'wind_gust_speed': 46.87, + 'wind_speed': 25.7, + }), + dict({ + 'apparent_temperature': 6.1, + 'condition': 'cloudy', + 'datetime': '2024-11-25T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 988.16, + 'temperature': 9.3, + 'uv_index': 0, + 'wind_bearing': 226, + 'wind_gust_speed': 44.71, + 'wind_speed': 24.88, + }), + dict({ + 'apparent_temperature': 5.8, + 'condition': 'cloudy', + 'datetime': '2024-11-25T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'pressure': 988.58, + 'temperature': 9.1, + 'uv_index': 0, + 'wind_bearing': 228, + 'wind_gust_speed': 45.22, + 'wind_speed': 25.34, + }), + dict({ + 'apparent_temperature': 5.4, + 'condition': 'clear-night', + 'datetime': '2024-11-25T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 989.1, + 'temperature': 8.8, + 'uv_index': 0, + 'wind_bearing': 232, + 'wind_gust_speed': 47.23, + 'wind_speed': 26.14, + }), + dict({ + 'apparent_temperature': 5.1, + 'condition': 'clear-night', + 'datetime': '2024-11-25T06:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 989.61, + 'temperature': 8.7, + 'uv_index': 0, + 'wind_bearing': 235, + 'wind_gust_speed': 48.2, + 'wind_speed': 26.35, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'clear-night', + 'datetime': '2024-11-25T07:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 990.61, + 'temperature': 8.6, + 'uv_index': 0, + 'wind_bearing': 240, + 'wind_gust_speed': 47.81, + 'wind_speed': 26.78, + }), + dict({ + 'apparent_temperature': 4.8, + 'condition': 'clear-night', + 'datetime': '2024-11-25T08:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 991.61, + 'temperature': 8.4, + 'uv_index': 0, + 'wind_bearing': 243, + 'wind_gust_speed': 47.56, + 'wind_speed': 26.86, + }), + dict({ + 'apparent_temperature': 4.8, + 'condition': 'sunny', + 'datetime': '2024-11-25T09:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 992.52, + 'temperature': 8.4, + 'uv_index': 1, + 'wind_bearing': 243, + 'wind_gust_speed': 47.84, + 'wind_speed': 27.32, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'pressure': 993.42, + 'temperature': 8.7, + 'uv_index': 1, + 'wind_bearing': 245, + 'wind_gust_speed': 49.79, + 'wind_speed': 28.8, + }), + dict({ + 'apparent_temperature': 5.0, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 3, + 'pressure': 994.24, + 'temperature': 8.8, + 'uv_index': 1, + 'wind_bearing': 249, + 'wind_gust_speed': 52.09, + 'wind_speed': 30.38, + }), + dict({ + 'apparent_temperature': 5.2, + 'condition': 'partlycloudy', + 'datetime': '2024-11-25T12:00:00+00:00', + 'precipitation': None, + 'precipitation_probability': 2, + 'pressure': 994.88, + 'temperature': 8.9, + 'uv_index': 1, + 'wind_bearing': 251, + 'wind_gust_speed': 52.16, + 'wind_speed': 30.67, }), ]) # --- diff --git a/tests/components/metoffice/test_config_flow.py b/tests/components/metoffice/test_config_flow.py index c2e75d89c1a..87d6e508da2 100644 --- a/tests/components/metoffice/test_config_flow.py +++ b/tests/components/metoffice/test_config_flow.py @@ -1,14 +1,18 @@ -"""Test the National Weather Service (NWS) config flow.""" +"""Test the MetOffice config flow.""" +import datetime import json from unittest.mock import patch +import pytest import requests_mock from homeassistant import config_entries from homeassistant.components.metoffice.const import DOMAIN +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from .const import ( METOFFICE_CONFIG_WAVERTREE, @@ -28,8 +32,11 @@ async def test_form(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> # all metoffice test data encapsulated in here mock_json = json.loads(load_fixture("metoffice.json", "metoffice")) - all_sites = json.dumps(mock_json["all_sites"]) - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) + wavertree_daily = json.dumps(mock_json["wavertree_daily"]) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text=wavertree_daily, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -66,17 +73,10 @@ async def test_form_already_configured( # all metoffice test data encapsulated in here mock_json = json.loads(load_fixture("metoffice.json", "metoffice")) - - all_sites = json.dumps(mock_json["all_sites"]) - - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) + wavertree_daily = json.dumps(mock_json["wavertree_daily"]) requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", - text="", - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", - text="", + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text=wavertree_daily, ) MockConfigEntry( @@ -102,7 +102,9 @@ async def test_form_cannot_connect( hass.config.latitude = TEST_LATITUDE_WAVERTREE hass.config.longitude = TEST_LONGITUDE_WAVERTREE - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text="") + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", text="" + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -122,7 +124,7 @@ async def test_form_unknown_error( ) -> None: """Test we handle unknown error.""" mock_instance = mock_simple_manager_fail.return_value - mock_instance.get_nearest_forecast_site.side_effect = ValueError + mock_instance.get_forecast.side_effect = ValueError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -135,3 +137,77 @@ async def test_form_unknown_error( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} + + +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +async def test_reauth_flow( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test handling authentication errors and reauth flow.""" + mock_json = json.loads(load_fixture("metoffice.json", "metoffice")) + wavertree_daily = json.dumps(mock_json["wavertree_daily"]) + wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text=wavertree_daily, + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text=wavertree_hourly, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(device_registry.devices) == 1 + + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text="", + status_code=401, + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text="", + status_code=401, + ) + + await entry.start_reauth_flow(hass) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_API_KEY: TEST_API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text=wavertree_daily, + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text=wavertree_hourly, + ) + + result = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_API_KEY: TEST_API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/metoffice/test_init.py b/tests/components/metoffice/test_init.py index 159587ca7c1..2152742625b 100644 --- a/tests/components/metoffice/test_init.py +++ b/tests/components/metoffice/test_init.py @@ -1,129 +1,65 @@ """Tests for metoffice init.""" -from __future__ import annotations - import datetime +import json import pytest import requests_mock -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.metoffice.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr +from homeassistant.util import utcnow -from .const import DOMAIN, METOFFICE_CONFIG_WAVERTREE, TEST_COORDINATES_WAVERTREE +from .const import METOFFICE_CONFIG_WAVERTREE -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) -@pytest.mark.parametrize( - ("old_unique_id", "new_unique_id", "migration_needed"), - [ - ( - f"Station Name_{TEST_COORDINATES_WAVERTREE}", - f"name_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Weather_{TEST_COORDINATES_WAVERTREE}", - f"weather_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Temperature_{TEST_COORDINATES_WAVERTREE}", - f"temperature_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Feels Like Temperature_{TEST_COORDINATES_WAVERTREE}", - f"feels_like_temperature_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Wind Speed_{TEST_COORDINATES_WAVERTREE}", - f"wind_speed_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Wind Direction_{TEST_COORDINATES_WAVERTREE}", - f"wind_direction_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Wind Gust_{TEST_COORDINATES_WAVERTREE}", - f"wind_gust_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Visibility_{TEST_COORDINATES_WAVERTREE}", - f"visibility_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Visibility Distance_{TEST_COORDINATES_WAVERTREE}", - f"visibility_distance_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"UV Index_{TEST_COORDINATES_WAVERTREE}", - f"uv_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Probability of Precipitation_{TEST_COORDINATES_WAVERTREE}", - f"precipitation_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"Humidity_{TEST_COORDINATES_WAVERTREE}", - f"humidity_{TEST_COORDINATES_WAVERTREE}", - True, - ), - ( - f"name_{TEST_COORDINATES_WAVERTREE}", - f"name_{TEST_COORDINATES_WAVERTREE}", - False, - ), - ("abcde", "abcde", False), - ], -) -async def test_migrate_unique_id( +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +async def test_reauth_on_auth_error( hass: HomeAssistant, - entity_registry: er.EntityRegistry, - old_unique_id: str, - new_unique_id: str, - migration_needed: bool, requests_mock: requests_mock.Mocker, + device_registry: dr.DeviceRegistry, ) -> None: - """Test unique id migration.""" + """Test handling authentication errors and reauth flow.""" + mock_json = json.loads(load_fixture("metoffice.json", "metoffice")) + wavertree_daily = json.dumps(mock_json["wavertree_daily"]) + wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text=wavertree_daily, + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text=wavertree_hourly, + ) entry = MockConfigEntry( domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE, ) entry.add_to_hass(hass) - - entity: er.RegistryEntry = entity_registry.async_get_or_create( - suggested_object_id="my_sensor", - disabled_by=None, - domain=SENSOR_DOMAIN, - platform=DOMAIN, - unique_id=old_unique_id, - config_entry=entry, - ) - assert entity.unique_id == old_unique_id - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - if migration_needed: - assert ( - entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) - is None - ) + assert len(device_registry.devices) == 1 - assert ( - entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, new_unique_id) - == "sensor.my_sensor" + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text="", + status_code=401, ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text="", + status_code=401, + ) + + future_time = utcnow() + datetime.timedelta(minutes=40) + async_fire_time_changed(hass, future_time) + await hass.async_block_till_done(wait_background_tasks=True) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index db84e85075e..dd2824e91b9 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -2,13 +2,15 @@ import datetime import json +import re import pytest import requests_mock from homeassistant.components.metoffice.const import ATTRIBUTION, DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import ( DEVICE_KEY_KINGSLYNN, @@ -17,34 +19,33 @@ from .const import ( METOFFICE_CONFIG_KINGSLYNN, METOFFICE_CONFIG_WAVERTREE, TEST_DATETIME_STRING, - TEST_SITE_NAME_KINGSLYNN, - TEST_SITE_NAME_WAVERTREE, + TEST_LATITUDE_WAVERTREE, + TEST_LONGITUDE_WAVERTREE, WAVERTREE_SENSOR_RESULTS, ) -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, get_sensor_display_state, load_fixture -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) async def test_one_sensor_site_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, requests_mock: requests_mock.Mocker, ) -> None: """Test the Met Office sensor platform.""" # all metoffice test data encapsulated in here mock_json = json.loads(load_fixture("metoffice.json", "metoffice")) - all_sites = json.dumps(mock_json["all_sites"]) wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", text=wavertree_hourly, ) requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", text=wavertree_daily, ) @@ -66,44 +67,39 @@ async def test_one_sensor_site_running( assert len(running_sensor_ids) > 0 for running_id in running_sensor_ids: sensor = hass.states.get(running_id) - sensor_id = sensor.attributes.get("sensor_id") - _, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] + sensor_id = re.search("met_office_wavertree_(.+?)$", running_id).group(1) + sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] - assert sensor.state == sensor_value + assert ( + get_sensor_display_state(hass, entity_registry, running_id) == sensor_value + ) assert sensor.attributes.get("last_update").isoformat() == TEST_DATETIME_STRING - assert sensor.attributes.get("site_id") == "354107" - assert sensor.attributes.get("site_name") == TEST_SITE_NAME_WAVERTREE assert sensor.attributes.get("attribution") == ATTRIBUTION -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) async def test_two_sensor_sites_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, requests_mock: requests_mock.Mocker, ) -> None: """Test we handle two sets of sensors running for two different sites.""" # all metoffice test data encapsulated in here mock_json = json.loads(load_fixture("metoffice.json", "metoffice")) - all_sites = json.dumps(mock_json["all_sites"]) wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) kingslynn_daily = json.dumps(mock_json["kingslynn_daily"]) - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text=wavertree_hourly, ) requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", text=wavertree_daily - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/322380?res=3hourly", text=kingslynn_hourly - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/322380?res=daily", text=kingslynn_daily + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text=wavertree_daily, ) entry = MockConfigEntry( @@ -112,6 +108,16 @@ async def test_two_sensor_sites_running( ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) + + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text=kingslynn_hourly, + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text=kingslynn_daily, + ) + entry2 = MockConfigEntry( domain=DOMAIN, data=METOFFICE_CONFIG_KINGSLYNN, @@ -134,25 +140,76 @@ async def test_two_sensor_sites_running( assert len(running_sensor_ids) > 0 for running_id in running_sensor_ids: sensor = hass.states.get(running_id) - sensor_id = sensor.attributes.get("sensor_id") - if sensor.attributes.get("site_id") == "354107": - _, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] - assert sensor.state == sensor_value + if "wavertree" in running_id: + sensor_id = re.search("met_office_wavertree_(.+?)$", running_id).group(1) + sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] + assert ( + get_sensor_display_state(hass, entity_registry, running_id) + == sensor_value + ) assert ( sensor.attributes.get("last_update").isoformat() == TEST_DATETIME_STRING ) - assert sensor.attributes.get("sensor_id") == sensor_id - assert sensor.attributes.get("site_id") == "354107" - assert sensor.attributes.get("site_name") == TEST_SITE_NAME_WAVERTREE assert sensor.attributes.get("attribution") == ATTRIBUTION else: - _, sensor_value = KINGSLYNN_SENSOR_RESULTS[sensor_id] - assert sensor.state == sensor_value + sensor_id = re.search("met_office_king_s_lynn_(.+?)$", running_id).group(1) + sensor_value = KINGSLYNN_SENSOR_RESULTS[sensor_id] + assert ( + get_sensor_display_state(hass, entity_registry, running_id) + == sensor_value + ) assert ( sensor.attributes.get("last_update").isoformat() == TEST_DATETIME_STRING ) - assert sensor.attributes.get("sensor_id") == sensor_id - assert sensor.attributes.get("site_id") == "322380" - assert sensor.attributes.get("site_name") == TEST_SITE_NAME_KINGSLYNN assert sensor.attributes.get("attribution") == ATTRIBUTION + + +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +@pytest.mark.parametrize( + ("old_unique_id"), + [ + f"visibility_distance_{TEST_LATITUDE_WAVERTREE}_{TEST_LONGITUDE_WAVERTREE}", + f"visibility_distance_{TEST_LATITUDE_WAVERTREE}_{TEST_LONGITUDE_WAVERTREE}_daily", + ], +) +async def test_legacy_entities_are_removed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + old_unique_id: str, +) -> None: + """Test the expected entities are deleted.""" + mock_json = json.loads(load_fixture("metoffice.json", "metoffice")) + wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) + wavertree_daily = json.dumps(mock_json["wavertree_daily"]) + + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text=wavertree_hourly, + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text=wavertree_daily, + ) + # Pre-create the entity + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id=old_unique_id, + suggested_object_id="met_office_wavertree_visibility_distance", + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + is None + ) diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 5176aff9e7d..48e7626a97f 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -47,29 +47,24 @@ async def wavertree_data(requests_mock: requests_mock.Mocker) -> dict[str, _Matc """Mock data for the Wavertree location.""" # all metoffice test data encapsulated in here mock_json = json.loads(load_fixture("metoffice.json", "metoffice")) - all_sites = json.dumps(mock_json["all_sites"]) wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) - sitelist_mock = requests_mock.get( - "/public/data/val/wxfcs/all/json/sitelist/", text=all_sites - ) wavertree_hourly_mock = requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", text=wavertree_hourly, ) wavertree_daily_mock = requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", text=wavertree_daily, ) return { - "sitelist_mock": sitelist_mock, "wavertree_hourly_mock": wavertree_hourly_mock, "wavertree_daily_mock": wavertree_daily_mock, } -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) async def test_site_cannot_connect( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -77,9 +72,14 @@ async def test_site_cannot_connect( ) -> None: """Test we handle cannot connect error.""" - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text="") - requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="") - requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=daily", text="") + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text="", + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text="", + ) entry = MockConfigEntry( domain=DOMAIN, @@ -91,15 +91,14 @@ async def test_site_cannot_connect( assert len(device_registry.devices) == 0 - assert hass.states.get("weather.met_office_wavertree_3hourly") is None - assert hass.states.get("weather.met_office_wavertree_daily") is None + assert hass.states.get("weather.met_office_wavertree") is None for sensor in WAVERTREE_SENSOR_RESULTS.values(): sensor_name = sensor[0] sensor = hass.states.get(f"sensor.wavertree_{sensor_name}") assert sensor is None -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) async def test_site_cannot_update( hass: HomeAssistant, requests_mock: requests_mock.Mocker, @@ -115,21 +114,43 @@ async def test_site_cannot_update( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - weather = hass.states.get("weather.met_office_wavertree_daily") + weather = hass.states.get("weather.met_office_wavertree") assert weather - requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="") - requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=daily", text="") + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text="", + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text="", + ) - future_time = utcnow() + timedelta(minutes=20) + future_time = utcnow() + timedelta(minutes=40) async_fire_time_changed(hass, future_time) await hass.async_block_till_done(wait_background_tasks=True) - weather = hass.states.get("weather.met_office_wavertree_daily") + weather = hass.states.get("weather.met_office_wavertree") + assert weather.state == STATE_UNAVAILABLE + + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + status_code=404, + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + status_code=404, + ) + + future_time = utcnow() + timedelta(minutes=40) + async_fire_time_changed(hass, future_time) + await hass.async_block_till_done(wait_background_tasks=True) + + weather = hass.states.get("weather.met_office_wavertree") assert weather.state == STATE_UNAVAILABLE -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) async def test_one_weather_site_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -153,17 +174,17 @@ async def test_one_weather_site_running( assert device_wavertree.name == "Met Office Wavertree" # Wavertree daily weather platform expected results - weather = hass.states.get("weather.met_office_wavertree_daily") + weather = hass.states.get("weather.met_office_wavertree") assert weather - assert weather.state == "sunny" - assert weather.attributes.get("temperature") == 19 - assert weather.attributes.get("wind_speed") == 14.48 - assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("humidity") == 50 + assert weather.state == "rainy" + assert weather.attributes.get("temperature") == 9.3 + assert weather.attributes.get("wind_speed") == 28.33 + assert weather.attributes.get("wind_bearing") == 176.0 + assert weather.attributes.get("humidity") == 95 -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) async def test_two_weather_sites_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -177,19 +198,23 @@ async def test_two_weather_sites_running( kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) kingslynn_daily = json.dumps(mock_json["kingslynn_daily"]) - requests_mock.get( - "/public/data/val/wxfcs/all/json/322380?res=3hourly", text=kingslynn_hourly - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/322380?res=daily", text=kingslynn_daily - ) - entry = MockConfigEntry( domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly", + text=kingslynn_hourly, + ) + requests_mock.get( + "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", + text=kingslynn_daily, + ) + entry2 = MockConfigEntry( domain=DOMAIN, data=METOFFICE_CONFIG_KINGSLYNN, @@ -209,29 +234,29 @@ async def test_two_weather_sites_running( assert device_wavertree.name == "Met Office Wavertree" # Wavertree daily weather platform expected results - weather = hass.states.get("weather.met_office_wavertree_daily") + weather = hass.states.get("weather.met_office_wavertree") assert weather - assert weather.state == "sunny" - assert weather.attributes.get("temperature") == 19 - assert weather.attributes.get("wind_speed") == 14.48 + assert weather.state == "rainy" + assert weather.attributes.get("temperature") == 9.3 + assert weather.attributes.get("wind_speed") == 28.33 assert weather.attributes.get("wind_speed_unit") == "km/h" - assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("humidity") == 50 + assert weather.attributes.get("wind_bearing") == 176.0 + assert weather.attributes.get("humidity") == 95 # King's Lynn daily weather platform expected results - weather = hass.states.get("weather.met_office_king_s_lynn_daily") + weather = hass.states.get("weather.met_office_king_s_lynn") assert weather - assert weather.state == "cloudy" - assert weather.attributes.get("temperature") == 9 - assert weather.attributes.get("wind_speed") == 6.44 + assert weather.state == "rainy" + assert weather.attributes.get("temperature") == 7.9 + assert weather.attributes.get("wind_speed") == 35.75 assert weather.attributes.get("wind_speed_unit") == "km/h" - assert weather.attributes.get("wind_bearing") == "ESE" - assert weather.attributes.get("humidity") == 75 + assert weather.attributes.get("wind_bearing") == 180.0 + assert weather.attributes.get("humidity") == 98 -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) async def test_new_config_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, no_sensor, wavertree_data ) -> None: @@ -250,7 +275,7 @@ async def test_new_config_entry( assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) @pytest.mark.parametrize( ("service"), [SERVICE_GET_FORECASTS], @@ -276,12 +301,12 @@ async def test_forecast_service( assert wavertree_data["wavertree_daily_mock"].call_count == 1 assert wavertree_data["wavertree_hourly_mock"].call_count == 1 - for forecast_type in ("daily", "hourly"): + for forecast_type in ("daily", "hourly", "twice_daily"): response = await hass.services.async_call( WEATHER_DOMAIN, service, { - "entity_id": "weather.met_office_wavertree_daily", + "entity_id": "weather.met_office_wavertree", "type": forecast_type, }, blocking=True, @@ -289,24 +314,17 @@ async def test_forecast_service( ) assert response == snapshot - # Calling the services should use cached data - assert wavertree_data["wavertree_daily_mock"].call_count == 1 - assert wavertree_data["wavertree_hourly_mock"].call_count == 1 - # Trigger data refetch freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert wavertree_data["wavertree_daily_mock"].call_count == 2 - assert wavertree_data["wavertree_hourly_mock"].call_count == 1 - - for forecast_type in ("daily", "hourly"): + for forecast_type in ("daily", "hourly", "twice_daily"): response = await hass.services.async_call( WEATHER_DOMAIN, service, { - "entity_id": "weather.met_office_wavertree_daily", + "entity_id": "weather.met_office_wavertree", "type": forecast_type, }, blocking=True, @@ -314,41 +332,18 @@ async def test_forecast_service( ) assert response == snapshot - # Calling the services should update the hourly forecast - assert wavertree_data["wavertree_daily_mock"].call_count == 2 - assert wavertree_data["wavertree_hourly_mock"].call_count == 2 - # Update fails - requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="") - - freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - - response = await hass.services.async_call( - WEATHER_DOMAIN, - service, - { - "entity_id": "weather.met_office_wavertree_daily", - "type": "hourly", - }, - blocking=True, - return_response=True, - ) - assert response == snapshot - - -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) async def test_legacy_config_entry_is_removed( hass: HomeAssistant, entity_registry: er.EntityRegistry, no_sensor, wavertree_data ) -> None: """Test the expected entities are created.""" - # Pre-create the hourly entity + # Pre-create the daily entity entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", - suggested_object_id="met_office_wavertree_3_hourly", + suggested_object_id="met_office_wavertree_daily", ) entry = MockConfigEntry( @@ -365,8 +360,7 @@ async def test_legacy_config_entry_is_removed( assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 -@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) -@pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) +@pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) async def test_forecast_subscription( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -374,7 +368,6 @@ async def test_forecast_subscription( snapshot: SnapshotAssertion, no_sensor, wavertree_data: dict[str, _Matcher], - forecast_type: str, ) -> None: """Test multiple forecast.""" client = await hass_ws_client(hass) @@ -391,8 +384,8 @@ async def test_forecast_subscription( await client.send_json_auto_id( { "type": "weather/subscribe_forecast", - "forecast_type": forecast_type, - "entity_id": "weather.met_office_wavertree_daily", + "forecast_type": "hourly", + "entity_id": "weather.met_office_wavertree", } ) msg = await client.receive_json() diff --git a/tests/components/miele/__init__.py b/tests/components/miele/__init__.py index b0278defa8e..2e75470c4a4 100644 --- a/tests/components/miele/__init__.py +++ b/tests/components/miele/__init__.py @@ -1,5 +1,8 @@ """Tests for the Miele integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -11,3 +14,13 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +def get_data_callback(mock: AsyncMock) -> Callable[[int], Awaitable[None]]: + """Get registered callback for api data push.""" + return mock.listen_events.call_args_list[0].kwargs.get("data_callback") + + +def get_actions_callback(mock: AsyncMock) -> Callable[[int], Awaitable[None]]: + """Get registered callback for api data push.""" + return mock.listen_events.call_args_list[0].kwargs.get("actions_callback") diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index 6df5b73ccc2..211c1d27814 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -15,6 +15,7 @@ from homeassistant.components.miele.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import get_actions_callback, get_data_callback from .const import CLIENT_ID, CLIENT_SECRET from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture @@ -141,7 +142,7 @@ async def setup_platform( 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 + yield mock_config_entry @pytest.fixture @@ -157,3 +158,21 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.miele.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +async def push_data_and_actions( + hass: HomeAssistant, + mock_miele_client: MagicMock, + device_fixture: MieleDevices, +) -> None: + """Fixture to push data and actions through mock.""" + + data_callback = get_data_callback(mock_miele_client) + await data_callback(device_fixture) + await hass.async_block_till_done() + + act_file = load_json_object_fixture("4_actions.json", DOMAIN) + action_callback = get_actions_callback(mock_miele_client) + await action_callback(act_file) + await hass.async_block_till_done() diff --git a/tests/components/miele/fixtures/4_actions.json b/tests/components/miele/fixtures/4_actions.json new file mode 100644 index 00000000000..6a89fb4604a --- /dev/null +++ b/tests/components/miele/fixtures/4_actions.json @@ -0,0 +1,86 @@ +{ + "Dummy_Appliance_1": { + "processAction": [4], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": -27, + "max": -13 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] + }, + "Dummy_Appliance_2": { + "processAction": [6], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": 1, + "max": 9 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] + }, + "Dummy_Appliance_3": { + "processAction": [1, 2, 3], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": 1, + "max": 9 + } + ], + "deviceName": true, + "powerOn": true, + "powerOff": false, + "colors": [], + "modes": [], + "runOnTime": [] + }, + "DummyAppliance_18": { + "processAction": [], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": 1, + "max": 9 + } + ], + "deviceName": true, + "powerOn": true, + "powerOff": false, + "colors": [], + "modes": [], + "runOnTime": [] + } +} diff --git a/tests/components/miele/fixtures/5_devices.json b/tests/components/miele/fixtures/5_devices.json index 93b5bf9f887..113babbd3f7 100644 --- a/tests/components/miele/fixtures/5_devices.json +++ b/tests/components/miele/fixtures/5_devices.json @@ -356,6 +356,106 @@ "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 + } + }, "Dummy_Appliance_4": { "ident": { "type": { @@ -402,10 +502,28 @@ { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } ], + "coreTargetTemperature": [ + { "value_raw": 7500, "value_localized": "75.0", "unit": "Celsius" } + ], + "coreTemperature": [ + { "value_raw": 5200, "value_localized": "52.0", "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" } + { + "value_raw": 17500, + "value_localized": "175.0", + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } ], "signalInfo": false, "signalFailure": false, @@ -459,7 +577,7 @@ }, "state": { "ProgramID": { - "value_raw": 99938, + "value_raw": 38, "value_localized": "QuickPowerWash", "key_localized": "Program name" }, @@ -469,12 +587,12 @@ "key_localized": "status" }, "programType": { - "value_raw": 9992, + "value_raw": 2, "value_localized": "Automatic programme", "key_localized": "Program type" }, "programPhase": { - "value_raw": 9991799, + "value_raw": 1799, "value_localized": "Drying", "key_localized": "Program phase" }, diff --git a/tests/components/miele/fixtures/action_push_vacuum.json b/tests/components/miele/fixtures/action_push_vacuum.json new file mode 100644 index 00000000000..f760d7e5e82 --- /dev/null +++ b/tests/components/miele/fixtures/action_push_vacuum.json @@ -0,0 +1,17 @@ +{ + "Dummy_Vacuum_1": { + "processAction": [], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [3], + "targetTemperature": [], + "deviceName": true, + "powerOn": true, + "powerOff": false, + "colors": [], + "modes": [], + "runOnTime": [] + } +} diff --git a/tests/components/miele/fixtures/action_washing_machine.json b/tests/components/miele/fixtures/action_washing_machine.json index 5e8e00306f4..c9b656363c8 100644 --- a/tests/components/miele/fixtures/action_washing_machine.json +++ b/tests/components/miele/fixtures/action_washing_machine.json @@ -5,7 +5,13 @@ "startTime": [], "ventilationStep": [], "programId": [], - "targetTemperature": [], + "targetTemperature": [ + { + "zone": 1, + "min": -28, + "max": 28 + } + ], "deviceName": true, "powerOn": true, "powerOff": false, diff --git a/tests/components/miele/fixtures/hob.json b/tests/components/miele/fixtures/hob.json new file mode 100644 index 00000000000..f86c6a0044f --- /dev/null +++ b/tests/components/miele/fixtures/hob.json @@ -0,0 +1,168 @@ +{ + "DummyAppliance_hob_w_extr": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 74, + "value_localized": "Hob with vapour extraction" + }, + "deviceName": "KDMA7774 | APP2-2", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KMDA7774-1 R01", + "matNumber": "10974770", + "swids": [ + "4088", + "20269", + "25122", + "4194", + "20270", + "25077", + "4194", + "20270", + "25077", + "4215", + "20270", + "25134", + "4438", + "20314", + "25128" + ] + }, + "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": "", + "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": 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": [ + { + "value_raw": 0, + "value_localized": 0, + "key_localized": "Power level" + }, + { + "value_raw": 110, + "value_localized": 2, + "key_localized": "Power level" + }, + { + "value_raw": 8, + "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 + } + } +} diff --git a/tests/components/miele/fixtures/vacuum_device.json b/tests/components/miele/fixtures/vacuum_device.json new file mode 100644 index 00000000000..5aa402a3493 --- /dev/null +++ b/tests/components/miele/fixtures/vacuum_device.json @@ -0,0 +1,82 @@ +{ + "Dummy_Vacuum_1": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 23, + "value_localized": "Robot vacuum cleaner" + }, + "deviceName": "", + "protocolVersion": 0, + "deviceIdentLabel": { + "fabNumber": "161173909", + "fabIndex": "32", + "techType": "RX3", + "matNumber": "11686510", + "swids": ["", "", "", "<...>"] + }, + "xkmIdentLabel": { + "techType": "", + "releaseVersion": "" + } + }, + "state": { + "ProgramID": { + "value_raw": 1, + "value_localized": "Auto", + "key_localized": "Program name" + }, + "status": { + "value_raw": 2, + "value_localized": "On", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 5889, + "value_localized": "in the base station", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [], + "temperature": [], + "coreTargetTemperature": [], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "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": 0, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": 65 + } + } +} diff --git a/tests/components/miele/snapshots/test_binary_sensor.ambr b/tests/components/miele/snapshots/test_binary_sensor.ambr index 9f5b886b0ba..f102c925c98 100644 --- a/tests/components/miele/snapshots/test_binary_sensor.ambr +++ b/tests/components/miele/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Door', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Dummy_Appliance_1-state_signal_door', @@ -75,6 +76,7 @@ 'original_name': 'Mobile start', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mobile_start', 'unique_id': 'Dummy_Appliance_1-state_mobile_start', @@ -122,6 +124,7 @@ 'original_name': 'Notification active', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_active', 'unique_id': 'Dummy_Appliance_1-state_signal_info', @@ -170,6 +173,7 @@ 'original_name': 'Problem', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Dummy_Appliance_1-state_signal_failure', @@ -218,6 +222,7 @@ 'original_name': 'Remote control', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': 'Dummy_Appliance_1-state_full_remote_control', @@ -265,6 +270,7 @@ 'original_name': 'Smart grid', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smart_grid', 'unique_id': 'Dummy_Appliance_1-state_smart_grid', @@ -312,6 +318,7 @@ 'original_name': 'Mobile start', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mobile_start', 'unique_id': 'DummyAppliance_18-state_mobile_start', @@ -359,6 +366,7 @@ 'original_name': 'Notification active', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_active', 'unique_id': 'DummyAppliance_18-state_signal_info', @@ -407,6 +415,7 @@ 'original_name': 'Problem', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'DummyAppliance_18-state_signal_failure', @@ -455,6 +464,7 @@ 'original_name': 'Remote control', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': 'DummyAppliance_18-state_full_remote_control', @@ -502,6 +512,7 @@ 'original_name': 'Smart grid', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smart_grid', 'unique_id': 'DummyAppliance_18-state_smart_grid', @@ -549,6 +560,7 @@ 'original_name': 'Door', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Dummy_Appliance_2-state_signal_door', @@ -597,6 +609,7 @@ 'original_name': 'Mobile start', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mobile_start', 'unique_id': 'Dummy_Appliance_2-state_mobile_start', @@ -644,6 +657,7 @@ 'original_name': 'Notification active', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_active', 'unique_id': 'Dummy_Appliance_2-state_signal_info', @@ -692,6 +706,7 @@ 'original_name': 'Problem', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Dummy_Appliance_2-state_signal_failure', @@ -740,6 +755,7 @@ 'original_name': 'Remote control', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': 'Dummy_Appliance_2-state_full_remote_control', @@ -787,6 +803,7 @@ 'original_name': 'Smart grid', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smart_grid', 'unique_id': 'Dummy_Appliance_2-state_smart_grid', @@ -834,6 +851,7 @@ 'original_name': 'Door', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Dummy_Appliance_3-state_signal_door', @@ -882,6 +900,7 @@ 'original_name': 'Mobile start', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mobile_start', 'unique_id': 'Dummy_Appliance_3-state_mobile_start', @@ -929,6 +948,7 @@ 'original_name': 'Notification active', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'notification_active', 'unique_id': 'Dummy_Appliance_3-state_signal_info', @@ -977,6 +997,7 @@ 'original_name': 'Problem', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Dummy_Appliance_3-state_signal_failure', @@ -1025,6 +1046,7 @@ 'original_name': 'Remote control', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': 'Dummy_Appliance_3-state_full_remote_control', @@ -1072,6 +1094,7 @@ 'original_name': 'Smart grid', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smart_grid', 'unique_id': 'Dummy_Appliance_3-state_smart_grid', @@ -1091,3 +1114,1118 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_states_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_18-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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 index b4f5ea5685a..6e6f3cbb72d 100644 --- a/tests/components/miele/snapshots/test_button.ambr +++ b/tests/components/miele/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Stop', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop', 'unique_id': 'DummyAppliance_18-stop', @@ -74,6 +75,7 @@ 'original_name': 'Pause', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pause', 'unique_id': 'Dummy_Appliance_3-pause', @@ -121,6 +123,7 @@ 'original_name': 'Start', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start', 'unique_id': 'Dummy_Appliance_3-start', @@ -168,6 +171,7 @@ 'original_name': 'Stop', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop', 'unique_id': 'Dummy_Appliance_3-stop', @@ -187,3 +191,195 @@ 'state': 'unknown', }) # --- +# name: test_button_states_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_18-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[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': 'unavailable', + }) +# --- +# name: test_button_states_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': 'Dummy_Appliance_3-pause', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'Dummy_Appliance_3-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'Dummy_Appliance_3-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[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 index 85f7bf212f5..0fb24c893c4 100644 --- a/tests/components/miele/snapshots/test_climate.ambr +++ b/tests/components/miele/snapshots/test_climate.ambr @@ -34,6 +34,7 @@ 'original_name': None, 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'freezer', 'unique_id': 'Dummy_Appliance_1-thermostat-1', @@ -97,6 +98,7 @@ 'original_name': None, 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'refrigerator', 'unique_id': 'Dummy_Appliance_2-thermostat-1', @@ -125,3 +127,131 @@ 'state': 'cool', }) # --- +# name: test_climate_states_api_push[platforms0-freezer][climate.freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -13, + 'min_temp': -27, + '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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'freezer', + 'unique_id': 'Dummy_Appliance_1-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_api_push[platforms0-freezer][climate.freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -18, + 'friendly_name': 'Freezer', + 'hvac_modes': list([ + , + ]), + 'max_temp': -13, + 'min_temp': -27, + '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_api_push[platforms0-freezer][climate.refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 9, + 'min_temp': 1, + '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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'refrigerator', + 'unique_id': 'Dummy_Appliance_2-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_api_push[platforms0-freezer][climate.refrigerator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4, + 'friendly_name': 'Refrigerator', + 'hvac_modes': list([ + , + ]), + 'max_temp': 9, + 'min_temp': 1, + '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 index aa564205867..8fa40755888 100644 --- a/tests/components/miele/snapshots/test_diagnostics.ambr +++ b/tests/components/miele/snapshots/test_diagnostics.ambr @@ -36,6 +36,11 @@ 'startTime': list([ ]), 'targetTemperature': list([ + dict({ + 'max': 28, + 'min': -28, + 'zone': 1, + }), ]), 'ventilationStep': list([ ]), @@ -64,6 +69,11 @@ 'startTime': list([ ]), 'targetTemperature': list([ + dict({ + 'max': 28, + 'min': -28, + 'zone': 1, + }), ]), 'ventilationStep': list([ ]), @@ -92,6 +102,11 @@ 'startTime': list([ ]), 'targetTemperature': list([ + dict({ + 'max': 28, + 'min': -28, + 'zone': 1, + }), ]), 'ventilationStep': list([ ]), @@ -120,6 +135,11 @@ 'startTime': list([ ]), 'targetTemperature': list([ + dict({ + 'max': 28, + 'min': -28, + 'zone': 1, + }), ]), 'ventilationStep': list([ ]), @@ -689,6 +709,11 @@ 'startTime': list([ ]), 'targetTemperature': list([ + dict({ + 'max': 28, + 'min': -28, + 'zone': 1, + }), ]), 'ventilationStep': list([ ]), diff --git a/tests/components/miele/snapshots/test_fan.ambr b/tests/components/miele/snapshots/test_fan.ambr index 595d4463462..8e5b3afd072 100644 --- a/tests/components/miele/snapshots/test_fan.ambr +++ b/tests/components/miele/snapshots/test_fan.ambr @@ -28,6 +28,7 @@ 'original_name': 'Fan', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan', 'unique_id': 'DummyAppliance_74-fan_readonly', @@ -77,6 +78,7 @@ 'original_name': 'Fan', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan', 'unique_id': 'DummyAppliance_74_off-fan_readonly', @@ -127,6 +129,7 @@ 'original_name': 'Fan', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'fan', 'unique_id': 'DummyAppliance_18-fan', @@ -151,3 +154,58 @@ 'state': 'off', }) # --- +# name: test_fan_states_api_push[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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'fan', + 'unique_id': 'DummyAppliance_18-fan', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_states_api_push[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_light.ambr b/tests/components/miele/snapshots/test_light.ambr index 128b642d7a0..8c4a4f4bff9 100644 --- a/tests/components/miele/snapshots/test_light.ambr +++ b/tests/components/miele/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': 'Ambient light', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ambient_light', 'unique_id': 'DummyAppliance_18-ambient_light', @@ -87,6 +88,7 @@ 'original_name': 'Light', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'DummyAppliance_18-light', @@ -111,3 +113,117 @@ 'state': 'on', }) # --- +# name: test_light_states_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ambient_light', + 'unique_id': 'DummyAppliance_18-ambient_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_18-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states_api_push[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 index 9cc2aa83b01..6984fcc4c50 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -1,4 +1,605 @@ # serializer version: 1 +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-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.hob_with_extraction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pot-steam-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_hob_w_extr-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction', + 'icon': 'mdi:pot-steam-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.hob_with_extraction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_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': 'Plate 1', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 1', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_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': 'Plate 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 2', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '110', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 3', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 4', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 4', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 5', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 5', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '117', + }) +# --- # name: test_sensor_states[platforms0][sensor.freezer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -48,6 +649,7 @@ 'original_name': None, 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', 'unique_id': 'Dummy_Appliance_1-state_status', @@ -113,12 +715,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Dummy_Appliance_1-state_temperature_1', @@ -190,6 +796,7 @@ 'original_name': None, 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', 'unique_id': 'DummyAppliance_18-state_status', @@ -280,6 +887,7 @@ 'original_name': None, 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', 'unique_id': 'Dummy_Appliance_2-state_status', @@ -345,12 +953,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Dummy_Appliance_2-state_temperature_1', @@ -422,6 +1034,7 @@ 'original_name': None, 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', 'unique_id': 'Dummy_Appliance_3-state_status', @@ -485,12 +1098,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Elapsed time', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'elapsed_time', 'unique_id': 'Dummy_Appliance_3-state_elapsed_time', @@ -536,12 +1153,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy consumption', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_consumption', 'unique_id': 'Dummy_Appliance_3-current_energy_consumption', @@ -564,6 +1185,55 @@ 'state': '0.0', }) # --- +# name: test_sensor_states[platforms0][sensor.washing_machine_energy_forecast-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_energy_forecast', + 'has_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 forecast', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_forecast', + 'unique_id': 'Dummy_Appliance_3-energy_forecast', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_energy_forecast-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Energy forecast', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_forecast', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- # name: test_sensor_states[platforms0][sensor.washing_machine_program-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -629,6 +1299,7 @@ 'original_name': 'Program', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'program_id', 'unique_id': 'Dummy_Appliance_3-state_program_id', @@ -735,6 +1406,7 @@ 'original_name': 'Program phase', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'program_phase', 'unique_id': 'Dummy_Appliance_3-state_program_phase', @@ -812,6 +1484,7 @@ 'original_name': 'Program type', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'program_type', 'unique_id': 'Dummy_Appliance_3-state_program_type', @@ -861,12 +1534,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining time', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_time', 'unique_id': 'Dummy_Appliance_3-state_remaining_time', @@ -916,6 +1593,7 @@ 'original_name': 'Spin speed', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'spin_speed', 'unique_id': 'Dummy_Appliance_3-state_spinning_speed', @@ -970,6 +1648,7 @@ 'original_name': 'Start in', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_time', 'unique_id': 'Dummy_Appliance_3-state_start_time', @@ -1015,12 +1694,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water consumption', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_consumption', 'unique_id': 'Dummy_Appliance_3-current_water_consumption', @@ -1043,3 +1726,1227 @@ 'state': '0.0', }) # --- +# name: test_sensor_states[platforms0][sensor.washing_machine_water_forecast-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_water_forecast', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water forecast', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_forecast', + 'unique_id': 'Dummy_Appliance_3-water_forecast', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_water_forecast-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Water forecast', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_water_forecast', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_1-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[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_api_push[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({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_1-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_18-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_2-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[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_api_push[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({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_2-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_3-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[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_api_push[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({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'Dummy_Appliance_3-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[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_api_push[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({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumption', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption', + 'unique_id': 'Dummy_Appliance_3-current_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[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_api_push[platforms0][sensor.washing_machine_energy_forecast-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_energy_forecast', + 'has_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 forecast', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_forecast', + 'unique_id': 'Dummy_Appliance_3-energy_forecast', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_energy_forecast-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Energy forecast', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_forecast', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensor_states_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'Dummy_Appliance_3-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[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_api_push[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, + 'suggested_object_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_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'Dummy_Appliance_3-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[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_api_push[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({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water consumption', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_consumption', + 'unique_id': 'Dummy_Appliance_3-current_water_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[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', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_forecast-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_water_forecast', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water forecast', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_forecast', + 'unique_id': 'Dummy_Appliance_3-water_forecast', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_forecast-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Water forecast', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_water_forecast', + '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 index b7f49f84eed..c8ca88c5b59 100644 --- a/tests/components/miele/snapshots/test_switch.ambr +++ b/tests/components/miele/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Superfreezing', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'superfreezing', 'unique_id': 'Dummy_Appliance_1-superfreezing', @@ -74,6 +75,7 @@ 'original_name': 'Power', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', 'unique_id': 'DummyAppliance_18-poweronoff', @@ -121,6 +123,7 @@ 'original_name': 'Supercooling', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supercooling', 'unique_id': 'Dummy_Appliance_2-supercooling', @@ -168,6 +171,7 @@ 'original_name': 'Power', 'platform': 'miele', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', 'unique_id': 'Dummy_Appliance_3-poweronoff', @@ -187,3 +191,195 @@ 'state': 'off', }) # --- +# name: test_switch_states_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'superfreezing', + 'unique_id': 'Dummy_Appliance_1-superfreezing', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_18-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supercooling', + 'unique_id': 'Dummy_Appliance_2-supercooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states_api_push[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_api_push[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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'Dummy_Appliance_3-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states_api_push[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/snapshots/test_vacuum.ambr b/tests/components/miele/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..9f96db7b05a --- /dev/null +++ b/tests/components/miele/snapshots/test_vacuum.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'normal', + 'turbo', + 'silent', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum_cleaner', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vacuum', + 'unique_id': 'Dummy_Vacuum_1-vacuum', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_icon': 'mdi:battery-60', + 'battery_level': 65, + 'fan_speed': 'normal', + 'fan_speed_list': list([ + 'normal', + 'turbo', + 'silent', + ]), + 'friendly_name': 'Robot vacuum cleaner', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum_cleaner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cleaning', + }) +# --- +# name: test_vacuum_states_api_push[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'normal', + 'turbo', + 'silent', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum_cleaner', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vacuum', + 'unique_id': 'Dummy_Vacuum_1-vacuum', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_states_api_push[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_icon': 'mdi:battery-60', + 'battery_level': 65, + 'fan_speed': 'normal', + 'fan_speed_list': list([ + 'normal', + 'turbo', + 'silent', + ]), + 'friendly_name': 'Robot vacuum cleaner', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum_cleaner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cleaning', + }) +# --- diff --git a/tests/components/miele/test_binary_sensor.py b/tests/components/miele/test_binary_sensor.py index fe1f4b896c5..02cdd7eafe1 100644 --- a/tests/components/miele/test_binary_sensor.py +++ b/tests/components/miele/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -17,11 +17,25 @@ from tests.common import MockConfigEntry, snapshot_platform 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, + setup_platform: MockConfigEntry, ) -> None: """Test binary sensor state.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_states_api_push( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, + push_data_and_actions: None, +) -> None: + """Test binary sensor state when the API pushes data via SSE.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/miele/test_button.py b/tests/components/miele/test_button.py index 9bf5f2f3f54..e4841707a18 100644 --- a/tests/components/miele/test_button.py +++ b/tests/components/miele/test_button.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientResponseError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID @@ -24,21 +24,34 @@ ENTITY_ID = "button.washing_machine_start" async def test_button_states( hass: HomeAssistant, mock_miele_client: MagicMock, - mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, - setup_platform: None, + setup_platform: MockConfigEntry, ) -> None: """Test button entity state.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_states_api_push( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, + push_data_and_actions: None, +) -> None: + """Test binary sensor state when the API pushes data via SSE.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_button_press( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, ) -> None: """Test button press.""" @@ -54,12 +67,14 @@ async def test_button_press( async def test_api_failure( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, ) -> None: """Test handling of exception from API.""" mock_miele_client.send_action.side_effect = ClientResponseError("test", "Test") - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match=f"Failed to set state for {ENTITY_ID}" + ): await hass.services.async_call( TEST_PLATFORM, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True ) diff --git a/tests/components/miele/test_climate.py b/tests/components/miele/test_climate.py index f03edada841..c4966430a9d 100644 --- a/tests/components/miele/test_climate.py +++ b/tests/components/miele/test_climate.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE @@ -33,20 +33,33 @@ 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, + setup_platform: MockConfigEntry, ) -> None: """Test climate entity state.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_states_api_push( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, + push_data_and_actions: None, +) -> None: + """Test climate state when the API pushes data via SSE.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_set_target( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, ) -> None: """Test the climate can be turned on/off.""" @@ -64,12 +77,14 @@ async def test_set_target( async def test_api_failure( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, ) -> None: """Test handling of exception from API.""" mock_miele_client.set_target_temperature.side_effect = ClientError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match=f"Failed to set state for {ENTITY_ID}" + ): await hass.services.async_call( TEST_PLATFORM, SERVICE_SET_TEMPERATURE, diff --git a/tests/components/miele/test_config_flow.py b/tests/components/miele/test_config_flow.py index 78478bc0e9d..bbe5844c1cd 100644 --- a/tests/components/miele/test_config_flow.py +++ b/tests/components/miele/test_config_flow.py @@ -225,6 +225,16 @@ async def test_zeroconf_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF} ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "oauth_discovery" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + state = config_entry_oauth2_flow._encode_jwt( hass, { diff --git a/tests/components/miele/test_diagnostics.py b/tests/components/miele/test_diagnostics.py index cf322b971c8..e613a4e512e 100644 --- a/tests/components/miele/test_diagnostics.py +++ b/tests/components/miele/test_diagnostics.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths from homeassistant.components.miele.const import DOMAIN diff --git a/tests/components/miele/test_fan.py b/tests/components/miele/test_fan.py index 87f80614551..557458e08dc 100644 --- a/tests/components/miele/test_fan.py +++ b/tests/components/miele/test_fan.py @@ -5,9 +5,13 @@ from unittest.mock import MagicMock from aiohttp import ClientResponseError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fan import ATTR_PERCENTAGE, DOMAIN as FAN_DOMAIN +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, +) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -25,14 +29,27 @@ ENTITY_ID = "fan.hood_fan" async def test_fan_states( hass: HomeAssistant, mock_miele_client: MagicMock, - mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, - setup_platform: None, + setup_platform: MockConfigEntry, ) -> None: """Test fan entity state.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_fan_states_api_push( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, + push_data_and_actions: None, +) -> None: + """Test fan state when the API pushes data via SSE.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("load_device_file", ["fan_devices.json"]) @@ -46,7 +63,7 @@ async def test_fan_states( async def test_fan_control( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, service: str, expected_argument: dict[str, Any], ) -> None: @@ -74,12 +91,12 @@ async def test_fan_control( async def test_fan_set_speed( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, service: str, percentage: int, expected_argument: dict[str, Any], ) -> None: - """Test the fan can be turned on/off.""" + """Test the fan can set percentage.""" await hass.services.async_call( TEST_PLATFORM, @@ -92,6 +109,24 @@ async def test_fan_set_speed( ) +async def test_fan_turn_on_w_percentage( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, +) -> None: + """Test the fan can turn on with percentage.""" + + await hass.services.async_call( + TEST_PLATFORM, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + mock_miele_client.send_action.assert_called_with( + "DummyAppliance_18", {"ventilationStep": 2} + ) + + @pytest.mark.parametrize( ("service"), [ @@ -102,7 +137,7 @@ async def test_fan_set_speed( async def test_api_failure( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, service: str, ) -> None: """Test handling of exception from API.""" @@ -113,3 +148,23 @@ async def test_api_failure( TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True ) mock_miele_client.send_action.assert_called_once() + + +async def test_set_percentage( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, +) -> None: + """Test handling of exception at set_percentage.""" + mock_miele_client.send_action.side_effect = ClientResponseError("test", "Test") + + with pytest.raises( + HomeAssistantError, match=f"Failed to set state for {ENTITY_ID}" + ): + await hass.services.async_call( + TEST_PLATFORM, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + 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 index 7a81ef78065..dae3d5ef79c 100644 --- a/tests/components/miele/test_init.py +++ b/tests/components/miele/test_init.py @@ -1,13 +1,15 @@ """Tests for init module.""" +from datetime import timedelta import http import time from unittest.mock import MagicMock from aiohttp import ClientConnectionError +from freezegun.api import FrozenDateTimeFactory from pymiele import OAUTH2_TOKEN import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.miele.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -17,7 +19,11 @@ from homeassistant.setup import async_setup_component from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -157,3 +163,48 @@ async def test_device_remove_devices( old_device_entry.id, mock_config_entry.entry_id ) assert response["success"] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_setup_all_platforms( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + load_device_file: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that all platforms can be set up.""" + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("binary_sensor.freezer_door").state == "off" + assert hass.states.get("binary_sensor.hood_problem").state == "off" + + assert ( + hass.states.get("button.washing_machine_start").object_id + == "washing_machine_start" + ) + + assert hass.states.get("climate.freezer").state == "cool" + assert hass.states.get("light.hood_light").state == "on" + + assert hass.states.get("sensor.freezer_temperature").state == "-18.0" + assert hass.states.get("sensor.washing_machine").state == "off" + + assert hass.states.get("switch.washing_machine_power").state == "off" + + # Add two devices and let the clock tick for 130 seconds + freezer.tick(timedelta(seconds=130)) + mock_miele_client.get_devices.return_value = load_json_object_fixture( + "5_devices.json", DOMAIN + ) + + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(device_registry.devices) == 6 + + # Check a sample sensor for each new device + assert hass.states.get("sensor.dishwasher").state == "in_use" + assert hass.states.get("sensor.oven_temperature").state == "175.0" diff --git a/tests/components/miele/test_light.py b/tests/components/miele/test_light.py index 286c2df0dd8..85f1fcd8d04 100644 --- a/tests/components/miele/test_light.py +++ b/tests/components/miele/test_light.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON @@ -23,14 +23,27 @@ 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, + setup_platform: MockConfigEntry, ) -> None: """Test light entity state.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_light_states_api_push( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, + push_data_and_actions: None, +) -> None: + """Test light state when the API pushes data via SSE.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize( @@ -43,7 +56,7 @@ async def test_light_states( async def test_light_toggle( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, service: str, light_state: int, ) -> None: @@ -67,13 +80,15 @@ async def test_light_toggle( async def test_api_failure( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, service: str, ) -> None: """Test handling of exception from API.""" mock_miele_client.send_action.side_effect = ClientError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match=f"Failed to set state for {ENTITY_ID}" + ): await hass.services.async_call( TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True ) diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 0a12a9e85e4..47e101c6636 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -17,11 +17,40 @@ from tests.common import MockConfigEntry, snapshot_platform async def test_sensor_states( hass: HomeAssistant, mock_miele_client: MagicMock, - mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test sensor state after polling the API for data.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_states_api_push( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, + push_data_and_actions: None, +) -> None: + """Test sensor state when the API pushes data via SSE.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["hob.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hob_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, 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) + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/miele/test_switch.py b/tests/components/miele/test_switch.py index fa5e9360da6..7115432cfba 100644 --- a/tests/components/miele/test_switch.py +++ b/tests/components/miele/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest -from syrupy import SnapshotAssertion +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 @@ -23,14 +23,27 @@ 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, + setup_platform: MockConfigEntry, ) -> None: """Test switch entity state.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switch_states_api_push( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, + push_data_and_actions: None, +) -> None: + """Test switch state when the API pushes data via SSE.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize( @@ -51,7 +64,7 @@ async def test_switch_states( async def test_switching( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, service: str, entity: str, ) -> None: @@ -81,14 +94,14 @@ async def test_switching( async def test_api_failure( hass: HomeAssistant, mock_miele_client: MagicMock, - setup_platform: None, + setup_platform: MockConfigEntry, service: str, entity: str, ) -> None: """Test handling of exception from API.""" mock_miele_client.send_action.side_effect = ClientError - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match=f"Failed to set state for {entity}"): await hass.services.async_call( TEST_PLATFORM, service, {ATTR_ENTITY_ID: entity}, blocking=True ) diff --git a/tests/components/miele/test_vacuum.py b/tests/components/miele/test_vacuum.py new file mode 100644 index 00000000000..6dc5b45f187 --- /dev/null +++ b/tests/components/miele/test_vacuum.py @@ -0,0 +1,147 @@ +"""Tests for miele vacuum module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +from pymiele import MieleDevices +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.miele.const import DOMAIN, PROCESS_ACTION, PROGRAM_ID +from homeassistant.components.vacuum import ( + ATTR_FAN_SPEED, + DOMAIN as VACUUM_DOMAIN, + SERVICE_CLEAN_SPOT, + SERVICE_PAUSE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_STOP, +) +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 . import get_actions_callback, get_data_callback + +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform + +TEST_PLATFORM = VACUUM_DOMAIN +ENTITY_ID = "vacuum.robot_vacuum_cleaner" + +pytestmark = [ + pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]), + pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]), +] + + +@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 vacuum entity setup.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_vacuum_states_api_push( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, + device_fixture: MieleDevices, +) -> None: + """Test vacuum state when the API pushes data via SSE.""" + + data_callback = get_data_callback(mock_miele_client) + await data_callback(device_fixture) + await hass.async_block_till_done() + + act_file = load_json_object_fixture("action_push_vacuum.json", DOMAIN) + action_callback = get_actions_callback(mock_miele_client) + await action_callback(act_file) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize( + ("service", "action_command", "vacuum_power"), + [ + (SERVICE_START, PROCESS_ACTION, 1), + (SERVICE_STOP, PROCESS_ACTION, 2), + (SERVICE_PAUSE, PROCESS_ACTION, 3), + (SERVICE_CLEAN_SPOT, PROGRAM_ID, 2), + ], +) +async def test_vacuum_program( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + vacuum_power: int | str, + action_command: str, +) -> None: + """Test the vacuum can be controlled.""" + + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once_with( + "Dummy_Vacuum_1", {action_command: vacuum_power} + ) + + +@pytest.mark.parametrize( + ("fan_speed", "expected"), [("normal", 1), ("turbo", 3), ("silent", 4)] +) +async def test_vacuum_fan_speed( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + fan_speed: str, + expected: int, +) -> None: + """Test the vacuum can be controlled.""" + + await hass.services.async_call( + TEST_PLATFORM, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_SPEED: fan_speed}, + blocking=True, + ) + mock_miele_client.send_action.assert_called_once_with( + "Dummy_Vacuum_1", {"programId": expected} + ) + + +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_START), + (SERVICE_STOP), + ], +) +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, match=f"Failed to set state for {ENTITY_ID}" + ): + 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/minecraft_server/const.py b/tests/components/minecraft_server/const.py index 6914d36ba5b..2c577e45d21 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -1,7 +1,7 @@ """Constants for Minecraft Server integration tests.""" from mcstatus.motd import Motd -from mcstatus.status_response import ( +from mcstatus.responses import ( BedrockStatusPlayers, BedrockStatusResponse, BedrockStatusVersion, @@ -44,6 +44,7 @@ TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( icon=None, enforces_secure_chat=False, latency=5, + forge_data=None, ) TEST_JAVA_DATA = MinecraftServerData( diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 77537a5e8e4..a3b71b2442f 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -5,9 +5,9 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from mcstatus import BedrockServer, JavaServer -from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant diff --git a/tests/components/minecraft_server/test_diagnostics.py b/tests/components/minecraft_server/test_diagnostics.py index e72d0c5f8db..d576b31ca5d 100644 --- a/tests/components/minecraft_server/test_diagnostics.py +++ b/tests/components/minecraft_server/test_diagnostics.py @@ -3,9 +3,9 @@ from unittest.mock import patch from mcstatus import BedrockServer, JavaServer -from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index a4cea239f7a..daa20d16a66 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -5,9 +5,9 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from mcstatus import BedrockServer, JavaServer -from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index fb124797523..c12a8f6818b 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -26,8 +26,8 @@ from homeassistant.util.unit_system import ( @pytest.mark.parametrize( ("unit_system", "state_unit", "state1", "state2"), [ - (METRIC_SYSTEM, UnitOfTemperature.CELSIUS, "100", "123"), - (US_CUSTOMARY_SYSTEM, UnitOfTemperature.FAHRENHEIT, "212", "253"), + (METRIC_SYSTEM, UnitOfTemperature.CELSIUS, 100, 123), + (US_CUSTOMARY_SYSTEM, UnitOfTemperature.FAHRENHEIT, 212, 253.4), ], ) async def test_sensor( @@ -83,7 +83,7 @@ async def test_sensor( assert entity.attributes["state_class"] == "measurement" assert entity.domain == "sensor" assert entity.name == "Test 1 Battery Temperature" - assert entity.state == state1 + assert float(entity.state) == state1 assert ( entity_registry.async_get("sensor.test_1_battery_temperature").entity_category @@ -113,7 +113,7 @@ async def test_sensor( assert json["invalid_state"]["success"] is False updated_entity = hass.states.get("sensor.test_1_battery_temperature") - assert updated_entity.state == state2 + assert float(updated_entity.state) == state2 assert "foo" not in updated_entity.attributes assert len(device_registry.devices) == len(create_registrations) @@ -135,21 +135,21 @@ async def test_sensor( @pytest.mark.parametrize( ("unique_id", "unit_system", "state_unit", "state1", "state2"), [ - ("battery_temperature", METRIC_SYSTEM, UnitOfTemperature.CELSIUS, "100", "123"), + ("battery_temperature", METRIC_SYSTEM, UnitOfTemperature.CELSIUS, 100, 123), ( "battery_temperature", US_CUSTOMARY_SYSTEM, UnitOfTemperature.FAHRENHEIT, - "212", - "253", + 212, + 253, ), # The unique_id doesn't match that of the mobile app's battery temperature sensor ( "battery_temp", US_CUSTOMARY_SYSTEM, UnitOfTemperature.FAHRENHEIT, - "212", - "123", + 212, + 123, ), ], ) @@ -205,7 +205,7 @@ async def test_sensor_migration( assert entity.attributes["state_class"] == "measurement" assert entity.domain == "sensor" assert entity.name == "Test 1 Battery Temperature" - assert entity.state == state1 + assert float(entity.state) == state1 # Reload to verify state is restored config_entry = hass.config_entries.async_entries("mobile_app")[1] @@ -244,7 +244,7 @@ async def test_sensor_migration( assert update_resp.status == HTTPStatus.OK updated_entity = hass.states.get("sensor.test_1_battery_temperature") - assert updated_entity.state == state2 + assert round(float(updated_entity.state), 0) == state2 assert "foo" not in updated_entity.attributes diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 745249ff866..56b6d0ef3b4 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -4,12 +4,18 @@ from pymodbus.exceptions import ModbusException import pytest from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + DOMAIN as LIGHT_DOMAIN, +) from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_BRIGHTNESS_REGISTER, + CONF_COLOR_TEMP_REGISTER, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_STATE_OFF, @@ -217,7 +223,23 @@ async def test_all_light(hass: HomeAssistant, mock_do_cycle, expected) -> None: @pytest.mark.parametrize( "mock_test_state", - [(State(ENTITY_ID, STATE_ON),)], + [ + ( + State( + ENTITY_ID, + STATE_ON, + { + ATTR_BRIGHTNESS: 128, + ATTR_COLOR_TEMP_KELVIN: 4000, + }, + ), + State( + ENTITY_ID2, + STATE_ON, + {}, + ), + ) + ], indirect=True, ) @pytest.mark.parametrize( @@ -229,16 +251,35 @@ async def test_all_light(hass: HomeAssistant, mock_do_cycle, expected) -> None: CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SCAN_INTERVAL: 0, - } + CONF_BRIGHTNESS_REGISTER: 1, + CONF_COLOR_TEMP_REGISTER: 2, + }, + { + CONF_NAME: f"{TEST_ENTITY_NAME} 2", + CONF_ADDRESS: 1235, + CONF_SCAN_INTERVAL: 0, + }, ] - }, + } ], ) async def test_restore_state_light( hass: HomeAssistant, mock_test_state, mock_modbus ) -> None: - """Run test for sensor restore state.""" - assert hass.states.get(ENTITY_ID).state == mock_test_state[0].state + """Test Modbus Light restore state with brightness and color_temp.""" + + state_1 = hass.states.get(ENTITY_ID) + state_2 = hass.states.get(ENTITY_ID2) + + assert state_1.state == STATE_ON + assert state_1.attributes.get(ATTR_BRIGHTNESS) == mock_test_state[0].attributes.get( + ATTR_BRIGHTNESS + ) + assert state_1.attributes.get(ATTR_COLOR_TEMP_KELVIN) == mock_test_state[ + 0 + ].attributes.get(ATTR_COLOR_TEMP_KELVIN) + + assert state_2.state == STATE_ON @pytest.mark.parametrize( @@ -271,7 +312,6 @@ async def test_light_service_turn( """Run test for service turn_on/turn_off.""" assert MODBUS_DOMAIN in hass.config.components - assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data={ATTR_ENTITY_ID: ENTITY_ID} @@ -307,21 +347,143 @@ async def test_light_service_turn( @pytest.mark.parametrize( - "do_config", + ("do_config", "service_data", "expected_calls"), [ - { - CONF_LIGHTS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_VERIFY: {}, - } - ] - }, + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, + CONF_BRIGHTNESS_REGISTER: 1, + CONF_COLOR_TEMP_REGISTER: 2, + } + ] + }, + {ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: 2000}, + [(1, 50), (2, 0)], + ), + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, + CONF_BRIGHTNESS_REGISTER: 1, + } + ] + }, + {ATTR_BRIGHTNESS: 256}, + [(1, 100)], + ), + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, + CONF_COLOR_TEMP_REGISTER: 2, + } + ] + }, + {ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: 3000}, + [(2, 20)], + ), ], ) -async def test_service_light_update(hass: HomeAssistant, mock_modbus_ha) -> None: +async def test_color_temp_brightness_light( + hass: HomeAssistant, + mock_modbus_ha, + service_data, + expected_calls, +) -> None: + """Test Modbus Light color temperature and brightness.""" + assert hass.states.get(ENTITY_ID).state == STATE_OFF + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: ENTITY_ID, **service_data}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).state == STATE_ON + calls = mock_modbus_ha.write_register.call_args_list + for expected_register, expected_value in expected_calls: + assert any( + call.args[0] == expected_register and call.kwargs["value"] == expected_value + for call in calls + ), ( + f"Expected register {expected_register} with value {expected_value} not found in calls {calls}" + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + +@pytest.mark.parametrize( + ( + "do_config", + "input_output_values", + ), + [ + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_BRIGHTNESS_REGISTER: 1, + CONF_COLOR_TEMP_REGISTER: 2, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + }, + ] + }, + [([100, 0], 255, 7000)], + ), + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_BRIGHTNESS_REGISTER: 1, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + }, + ] + }, + [([100, None], 255, None), ([0, None], 0, None)], + ), + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + }, + ] + }, + [([None, None], None, None)], + ), + ], +) +async def test_service_light_update( + hass: HomeAssistant, + mock_modbus_ha, + input_output_values, +) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( HOMEASSISTANT_DOMAIN, @@ -338,6 +500,31 @@ async def test_service_light_update(hass: HomeAssistant, mock_modbus_ha) -> None blocking=True, ) assert hass.states.get(ENTITY_ID).state == STATE_ON + for ( + register_values, + expected_brightness, + expected_color_temp, + ) in input_output_values: + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_values) + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + { + ATTR_ENTITY_ID: ENTITY_ID, + }, + blocking=True, + ) + assert ( + expected_brightness is None + or hass.states.get(ENTITY_ID).attributes.get(ATTR_BRIGHTNESS) + == expected_brightness + ) + assert ( + expected_color_temp is None + or hass.states.get(ENTITY_ID).attributes.get(ATTR_COLOR_TEMP_KELVIN) + == expected_color_temp + ) + assert hass async def test_no_discovery_info_light( diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index fc63a300c5c..4910b4df065 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -428,7 +428,7 @@ async def test_config_wrong_struct_sensor( }, [0x89AB], False, - STATE_UNAVAILABLE, + STATE_UNKNOWN, ), ( { @@ -631,7 +631,7 @@ async def test_config_wrong_struct_sensor( }, [0x8000, 0x0000], False, - STATE_UNAVAILABLE, + STATE_UNKNOWN, ), ( { @@ -742,7 +742,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: int.from_bytes(struct.pack(">f", float("nan"))[2:4]), ], False, - ["34899771392.0", "0.0"], + ["34899771392.0", STATE_UNKNOWN], ), ( { @@ -757,7 +757,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: int.from_bytes(struct.pack(">f", float("nan"))[2:4]), ], False, - ["34899771392.0", "0.0"], + ["34899771392.0", STATE_UNKNOWN], ), ( { @@ -802,7 +802,11 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: }, [0x0102, 0x0304, 0x0403, 0x0201, 0x0403], False, - [STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNKNOWN], + [ + STATE_UNKNOWN, + STATE_UNKNOWN, + STATE_UNKNOWN, + ], ), ( { @@ -857,7 +861,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: }, [0x0102, 0x0304, 0x0403, 0x0201], True, - [STATE_UNAVAILABLE, STATE_UNKNOWN], + [STATE_UNAVAILABLE, STATE_UNAVAILABLE], ), ( { @@ -866,7 +870,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: }, [0x0102, 0x0304, 0x0403, 0x0201], True, - [STATE_UNAVAILABLE, STATE_UNKNOWN], + [STATE_UNAVAILABLE, STATE_UNAVAILABLE], ), ( { @@ -875,7 +879,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: }, [], False, - [STATE_UNAVAILABLE, STATE_UNKNOWN], + [STATE_UNKNOWN, STATE_UNKNOWN], ), ( { @@ -884,7 +888,35 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: }, [], False, - [STATE_UNAVAILABLE, STATE_UNKNOWN], + [STATE_UNKNOWN, STATE_UNKNOWN], + ), + ( + { + CONF_VIRTUAL_COUNT: 4, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.INT32, + CONF_NAN_VALUE: "0x800000", + }, + [ + 0x0, + 0x35, + 0x0, + 0x38, + 0x80, + 0x0, + 0x80, + 0x0, + 0xFFFF, + 0xFFF6, + ], + False, + [ + "53", + "56", + STATE_UNKNOWN, + STATE_UNKNOWN, + "-10", + ], ), ], ) @@ -1103,7 +1135,7 @@ async def test_virtual_swap_sensor( ) async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: """Run test for sensor.""" - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + assert hass.states.get(ENTITY_ID).state == STATE_UNKNOWN @pytest.mark.parametrize( @@ -1131,14 +1163,14 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: int.from_bytes(struct.pack(">f", float("nan"))[0:2]), int.from_bytes(struct.pack(">f", float("nan"))[2:4]), ], - STATE_UNAVAILABLE, + STATE_UNKNOWN, ), ( { CONF_DATA_TYPE: DataType.FLOAT32, }, [0x6E61, 0x6E00], - STATE_UNAVAILABLE, + STATE_UNKNOWN, ), ( { @@ -1147,7 +1179,7 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: CONF_STRUCTURE: "4s", }, [0x6E61, 0x6E00], - STATE_UNAVAILABLE, + STATE_UNKNOWN, ), ( { diff --git a/tests/components/modern_forms/test_diagnostics.py b/tests/components/modern_forms/test_diagnostics.py index 9eb2e4efa94..10a4c8385fa 100644 --- a/tests/components/modern_forms/test_diagnostics.py +++ b/tests/components/modern_forms/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the Modern Forms diagnostics platform.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr index 461cb33d776..5ea055b5347 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Büro IO device 1 battery', 'platform': 'moehlenhoff_alpha2', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Alpha2Test:1:battery', diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr index 27244d781df..9104b7473b4 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Sync time', 'platform': 'moehlenhoff_alpha2', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '6fa019921cf8e7a3f57a3c2ed001a10d:sync_time', diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr index 0708137e1cf..57f1b2fdc25 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr @@ -40,6 +40,7 @@ 'original_name': 'Büro', 'platform': 'moehlenhoff_alpha2', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'Alpha2Test:1', diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr index 4b1c702591d..28df23dd089 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Büro heat control 1 valve opening', 'platform': 'moehlenhoff_alpha2', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Alpha2Test:1:valve_opening', diff --git a/tests/components/moehlenhoff_alpha2/test_binary_sensor.py b/tests/components/moehlenhoff_alpha2/test_binary_sensor.py index e650e9f9ba6..f9fbe60fb44 100644 --- a/tests/components/moehlenhoff_alpha2/test_binary_sensor.py +++ b/tests/components/moehlenhoff_alpha2/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/moehlenhoff_alpha2/test_button.py b/tests/components/moehlenhoff_alpha2/test_button.py index d4465746d53..09ffd1134ea 100644 --- a/tests/components/moehlenhoff_alpha2/test_button.py +++ b/tests/components/moehlenhoff_alpha2/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/moehlenhoff_alpha2/test_climate.py b/tests/components/moehlenhoff_alpha2/test_climate.py index a32f2b5bd4f..a9e46167693 100644 --- a/tests/components/moehlenhoff_alpha2/test_climate.py +++ b/tests/components/moehlenhoff_alpha2/test_climate.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/moehlenhoff_alpha2/test_sensor.py b/tests/components/moehlenhoff_alpha2/test_sensor.py index 931c744faea..6f89d8ce306 100644 --- a/tests/components/moehlenhoff_alpha2/test_sensor.py +++ b/tests/components/moehlenhoff_alpha2/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/mold_indicator/test_config_flow.py b/tests/components/mold_indicator/test_config_flow.py index bb8362b5e0d..aca6e37ff92 100644 --- a/tests/components/mold_indicator/test_config_flow.py +++ b/tests/components/mold_indicator/test_config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.mold_indicator.const import ( diff --git a/tests/components/monarch_money/snapshots/test_sensor.ambr b/tests/components/monarch_money/snapshots/test_sensor.ambr index b70302188ed..65f85925114 100644 --- a/tests/components/monarch_money/snapshots/test_sensor.ambr +++ b/tests/components/monarch_money/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Expense year to date', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_expense', 'unique_id': '222260252323873333_cashflow_sum_expense', @@ -81,6 +82,7 @@ 'original_name': 'Income year to date', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_income', 'unique_id': '222260252323873333_cashflow_sum_income', @@ -134,6 +136,7 @@ 'original_name': 'Savings rate', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'savings_rate', 'unique_id': '222260252323873333_cashflow_savings_rate', @@ -184,6 +187,7 @@ 'original_name': 'Savings year to date', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'savings', 'unique_id': '222260252323873333_cashflow_savings', @@ -236,6 +240,7 @@ 'original_name': 'Balance', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': '222260252323873333_186321412999033223_balance', @@ -287,6 +292,7 @@ 'original_name': 'Data age', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': '222260252323873333_186321412999033223_age', @@ -338,6 +344,7 @@ 'original_name': 'Balance', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': '222260252323873333_900000002_balance', @@ -390,6 +397,7 @@ 'original_name': 'Data age', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': '222260252323873333_900000002_age', @@ -441,6 +449,7 @@ 'original_name': 'Balance', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': '222260252323873333_900000000_balance', @@ -493,6 +502,7 @@ 'original_name': 'Data age', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': '222260252323873333_900000000_age', @@ -544,6 +554,7 @@ 'original_name': 'Balance', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': '222260252323873333_9000000007_balance', @@ -596,6 +607,7 @@ 'original_name': 'Data age', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': '222260252323873333_9000000007_age', @@ -647,6 +659,7 @@ 'original_name': 'Balance', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': '222260252323873333_90000000022_balance', @@ -699,6 +712,7 @@ 'original_name': 'Data age', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': '222260252323873333_90000000022_age', @@ -750,6 +764,7 @@ 'original_name': 'Balance', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': '222260252323873333_900000000012_balance', @@ -802,6 +817,7 @@ 'original_name': 'Data age', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': '222260252323873333_900000000012_age', @@ -853,6 +869,7 @@ 'original_name': 'Balance', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': '222260252323873333_90000000030_balance', @@ -905,6 +922,7 @@ 'original_name': 'Data age', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': '222260252323873333_90000000030_age', @@ -954,6 +972,7 @@ 'original_name': 'Data age', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': '222260252323873333_121212192626186051_age', @@ -1005,6 +1024,7 @@ 'original_name': 'Value', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'value', 'unique_id': '222260252323873333_121212192626186051_value', @@ -1059,6 +1079,7 @@ 'original_name': 'Balance', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': '222260252323873333_90000000020_balance', @@ -1111,6 +1132,7 @@ 'original_name': 'Data age', 'platform': 'monarch_money', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': '222260252323873333_90000000020_age', diff --git a/tests/components/monarch_money/test_sensor.py b/tests/components/monarch_money/test_sensor.py index aac1eaefb2d..1fe1b8cdb12 100644 --- a/tests/components/monarch_money/test_sensor.py +++ b/tests/components/monarch_money/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/monzo/snapshots/test_sensor.ambr b/tests/components/monzo/snapshots/test_sensor.ambr index 8d3f83ed4f1..bd6fd4c5daf 100644 --- a/tests/components/monzo/snapshots/test_sensor.ambr +++ b/tests/components/monzo/snapshots/test_sensor.ambr @@ -30,6 +30,7 @@ 'original_name': 'Balance', 'platform': 'monzo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'acc_curr_balance', @@ -83,6 +84,7 @@ 'original_name': 'Total balance', 'platform': 'monzo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_balance', 'unique_id': 'acc_curr_total_balance', @@ -136,6 +138,7 @@ 'original_name': 'Balance', 'platform': 'monzo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'acc_flex_balance', @@ -189,6 +192,7 @@ 'original_name': 'Total balance', 'platform': 'monzo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_balance', 'unique_id': 'acc_flex_total_balance', @@ -242,6 +246,7 @@ 'original_name': 'Balance', 'platform': 'monzo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pot_balance', 'unique_id': 'pot_savings_pot_balance', diff --git a/tests/components/monzo/test_sensor.py b/tests/components/monzo/test_sensor.py index a57466fdbd4..c4b55d11c36 100644 --- a/tests/components/monzo/test_sensor.py +++ b/tests/components/monzo/test_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from monzopy import InvalidMonzoAPIResponseError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.monzo.const import DOMAIN from homeassistant.components.monzo.sensor import ( diff --git a/tests/components/motionblinds_ble/test_diagnostics.py b/tests/components/motionblinds_ble/test_diagnostics.py index 878d2caa326..6d041a2df8b 100644 --- a/tests/components/motionblinds_ble/test_diagnostics.py +++ b/tests/components/motionblinds_ble/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Motionblinds Bluetooth diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/motionblinds_ble/test_entity.py b/tests/components/motionblinds_ble/test_entity.py index 00369ba1e22..eee234a03be 100644 --- a/tests/components/motionblinds_ble/test_entity.py +++ b/tests/components/motionblinds_ble/test_entity.py @@ -52,4 +52,4 @@ async def test_entity_update( {ATTR_ENTITY_ID: f"{platform.name.lower()}.{name}_{entity}"}, blocking=True, ) - getattr(mock_motion_device, "status_query").assert_called_once_with() + mock_motion_device.status_query.assert_called_once_with() diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 3e920757f6b..b985a8caffe 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -66,6 +66,105 @@ DEFAULT_CONFIG_DEVICE_INFO_MAC = { "configuration_url": "http://example.com", } +MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT = { + "5b06357ef8654e8d9c54cee5bb0e939b": { + "platform": "binary_sensor", + "name": "Hatch", + "device_class": "door", + "state_topic": "test-topic", + "payload_on": "ON", + "payload_off": "OFF", + "expire_after": 1200, + "off_delay": 5, + "value_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/5b06357ef8654e8d9c54cee5bb0e939b", + }, +} +MOCK_SUBENTRY_BUTTON_COMPONENT = { + "365d05e6607c4dfb8ae915cff71a954b": { + "platform": "button", + "name": "Restart", + "device_class": "restart", + "command_topic": "test-topic", + "payload_press": "PRESS", + "command_template": "{{ value }}", + "retain": False, + "entity_picture": "https://example.com/365d05e6607c4dfb8ae915cff71a954b", + }, +} +MOCK_SUBENTRY_COVER_COMPONENT = { + "b37acf667fa04c688ad7dfb27de2178b": { + "platform": "cover", + "name": "Blind", + "device_class": "blind", + "command_topic": "test-topic", + "payload_stop": None, + "payload_stop_tilt": "STOP", + "payload_open": "OPEN", + "payload_close": "CLOSE", + "position_closed": 0, + "position_open": 100, + "position_template": "{{ value_json.position }}", + "position_topic": "test-topic/position", + "set_position_template": "{{ value }}", + "set_position_topic": "test-topic/position-set", + "state_closed": "closed", + "state_closing": "closing", + "state_open": "open", + "state_opening": "opening", + "state_stopped": "stopped", + "state_topic": "test-topic", + "tilt_closed_value": 0, + "tilt_max": 100, + "tilt_min": 0, + "tilt_opened_value": 100, + "tilt_optimistic": False, + "tilt_command_topic": "test-topic/tilt-set", + "tilt_command_template": "{{ value }}", + "tilt_status_topic": "test-topic/tilt", + "tilt_status_template": "{{ value_json.position }}", + "retain": False, + "entity_picture": "https://example.com/b37acf667fa04c688ad7dfb27de2178b", + }, +} +MOCK_SUBENTRY_FAN_COMPONENT = { + "717f924ae9ca4fe9864d845d75d23c9f": { + "platform": "fan", + "name": "Breezer", + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{ value }}", + "value_template": "{{ value_json.value }}", + "percentage_command_topic": "test-topic/pct", + "percentage_state_topic": "test-topic/pct", + "percentage_command_template": "{{ value }}", + "percentage_value_template": "{{ value_json.percentage }}", + "payload_reset_percentage": "None", + "preset_modes": ["eco", "auto"], + "preset_mode_command_topic": "test-topic/prm", + "preset_mode_state_topic": "test-topic/prm", + "preset_mode_command_template": "{{ value }}", + "preset_mode_value_template": "{{ value_json.preset_mode }}", + "payload_reset_preset_mode": "None", + "oscillation_command_topic": "test-topic/osc", + "oscillation_state_topic": "test-topic/osc", + "oscillation_command_template": "{{ value }}", + "oscillation_value_template": "{{ value_json.oscillation }}", + "payload_oscillation_off": "oscillate_off", + "payload_oscillation_on": "oscillate_on", + "direction_command_topic": "test-topic/dir", + "direction_state_topic": "test-topic/dir", + "direction_command_template": "{{ value }}", + "direction_value_template": "{{ value_json.direction }}", + "payload_off": "OFF", + "payload_on": "ON", + "entity_picture": "https://example.com/717f924ae9ca4fe9864d845d75d23c9f", + "optimistic": False, + "retain": False, + "speed_range_max": 100, + "speed_range_min": 1, + }, +} MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "363a7ecad6be4a19b939a016ea93e994": { "platform": "notify", @@ -135,6 +234,8 @@ MOCK_SUBENTRY_SWITCH_COMPONENT = { "state_topic": "test-topic", "command_template": "{{ value }}", "value_template": "{{ value_json.value }}", + "payload_off": "OFF", + "payload_on": "ON", "entity_picture": "https://example.com/3faf1318016c46c5aea26707eeb6f12e", "optimistic": True, }, @@ -191,6 +292,22 @@ MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, } | MOCK_SUBENTRY_AVAILABILITY_DATA +MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, + "components": MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT, +} +MOCK_BUTTON_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, + "components": MOCK_SUBENTRY_BUTTON_COMPONENT, +} +MOCK_COVER_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_COVER_COMPONENT, +} +MOCK_FAN_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_FAN_COMPONENT, +} MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, @@ -1841,7 +1958,6 @@ async def help_test_entity_icon_and_entity_picture( mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, - default_entity_picture: str | None = None, ) -> None: """Test entity picture and icon.""" await mqtt_mock_entry() @@ -1861,7 +1977,7 @@ async def help_test_entity_icon_and_entity_picture( state = hass.states.get(entity_id) assert entity_id is not None and state assert state.attributes.get("icon") is None - assert state.attributes.get("entity_picture") == default_entity_picture + assert state.attributes.get("entity_picture") is None # Discover an entity with an entity picture set unique_id = "veryunique2" @@ -1888,7 +2004,7 @@ async def help_test_entity_icon_and_entity_picture( state = hass.states.get(entity_id) assert entity_id is not None and state assert state.attributes.get("icon") == "mdi:emoji-happy-outline" - assert state.attributes.get("entity_picture") == default_entity_picture + assert state.attributes.get("entity_picture") is None async def help_test_publishing_with_custom_encoding( diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 11f5b9d5c9e..e30aa5d50d6 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -33,6 +33,10 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( + MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, + MOCK_BUTTON_SUBENTRY_DATA_SINGLE, + MOCK_COVER_SUBENTRY_DATA_SINGLE, + MOCK_FAN_SUBENTRY_DATA_SINGLE, MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, MOCK_NOTIFY_SUBENTRY_DATA_MULTI, MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, @@ -2657,6 +2661,282 @@ async def test_migrate_of_incompatible_config_entry( "entity_name", ), [ + ( + MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, + {"name": "Hatch"}, + {"device_class": "door"}, + (), + { + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "advanced_settings": {"expire_after": 1200, "off_delay": 5}, + }, + ( + ( + {"state_topic": "test-topic#invalid"}, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Hatch", + ), + ( + MOCK_BUTTON_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, + {"name": "Restart"}, + {"device_class": "restart"}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "payload_press": "PRESS", + "retain": False, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), + "Milk notifier Restart", + ), + ( + MOCK_COVER_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Blind"}, + {"device_class": "blind"}, + (), + { + "command_topic": "test-topic", + "cover_position_settings": { + "position_template": "{{ value_json.position }}", + "position_topic": "test-topic/position", + "set_position_template": "{{ value }}", + "set_position_topic": "test-topic/position-set", + }, + "state_topic": "test-topic", + "retain": False, + "cover_tilt_settings": { + "tilt_command_topic": "test-topic/tilt-set", + "tilt_command_template": "{{ value }}", + "tilt_status_topic": "test-topic/tilt", + "tilt_status_template": "{{ value_json.position }}", + "tilt_closed_value": 0, + "tilt_opened_value": 100, + "tilt_max": 100, + "tilt_min": 0, + "tilt_optimistic": False, + }, + }, + ( + ( + {"value_template": "{{ json_value.state }}"}, + { + "value_template": "cover_value_template_must_be_used_with_state_topic" + }, + ), + ( + {"cover_position_settings": {"set_position_topic": "test-topic"}}, + { + "cover_position_settings": "cover_get_and_set_position_must_be_set_together" + }, + ), + ( + { + "cover_position_settings": { + "set_position_template": "{{ value }}" + } + }, + { + "cover_position_settings": "cover_set_position_template_must_be_used_with_set_position_topic" + }, + ), + ( + { + "cover_position_settings": { + "position_template": "{{ json_value.position }}" + } + }, + { + "cover_position_settings": "cover_get_position_template_must_be_used_with_get_position_topic" + }, + ), + ( + {"cover_position_settings": {"set_position_topic": "{{ value }}"}}, + { + "cover_position_settings": "cover_get_and_set_position_must_be_set_together" + }, + ), + ( + {"cover_tilt_settings": {"tilt_command_template": "{{ value }}"}}, + { + "cover_tilt_settings": "cover_tilt_command_template_must_be_used_with_tilt_command_topic" + }, + ), + ( + { + "cover_tilt_settings": { + "tilt_status_template": "{{ json_value.position }}" + } + }, + { + "cover_tilt_settings": "cover_tilt_status_template_must_be_used_with_tilt_status_topic" + }, + ), + ), + "Milk notifier Blind", + ), + ( + MOCK_FAN_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Breezer"}, + { + "fan_feature_speed": True, + "fan_feature_preset_modes": True, + "fan_feature_oscillation": True, + "fan_feature_direction": True, + }, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "fan_speed_settings": { + "percentage_command_template": "{{ value }}", + "percentage_command_topic": "test-topic/pct", + "percentage_state_topic": "test-topic/pct", + "percentage_value_template": "{{ value_json.percentage }}", + "speed_range_min": 1, + "speed_range_max": 100, + "payload_reset_percentage": "None", + }, + "fan_preset_mode_settings": { + "preset_modes": ["eco", "auto"], + "preset_mode_command_template": "{{ value }}", + "preset_mode_command_topic": "test-topic/prm", + "preset_mode_state_topic": "test-topic/prm", + "preset_mode_value_template": "{{ value_json.preset_mode }}", + "payload_reset_preset_mode": "None", + }, + "fan_oscillation_settings": { + "oscillation_command_template": "{{ value }}", + "oscillation_command_topic": "test-topic/osc", + "oscillation_state_topic": "test-topic/osc", + "oscillation_value_template": "{{ value_json.oscillation }}", + }, + "fan_direction_settings": { + "direction_command_template": "{{ value }}", + "direction_command_topic": "test-topic/dir", + "direction_state_topic": "test-topic/dir", + "direction_value_template": "{{ value_json.direction }}", + }, + "retain": False, + "optimistic": False, + }, + ( + ( + { + "command_topic": "test-topic#invalid", + "fan_speed_settings": { + "percentage_command_topic": "test-topic#invalid", + }, + "fan_preset_mode_settings": { + "preset_modes": ["eco", "auto"], + "preset_mode_command_topic": "test-topic#invalid", + }, + "fan_oscillation_settings": { + "oscillation_command_topic": "test-topic#invalid", + }, + "fan_direction_settings": { + "direction_command_topic": "test-topic#invalid", + }, + }, + { + "command_topic": "invalid_publish_topic", + "fan_preset_mode_settings": "invalid_publish_topic", + "fan_speed_settings": "invalid_publish_topic", + "fan_oscillation_settings": "invalid_publish_topic", + "fan_direction_settings": "invalid_publish_topic", + }, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + "fan_speed_settings": { + "percentage_command_topic": "test-topic", + "percentage_state_topic": "test-topic#invalid", + }, + "fan_preset_mode_settings": { + "preset_modes": ["eco", "auto"], + "preset_mode_command_topic": "test-topic", + "preset_mode_state_topic": "test-topic#invalid", + }, + "fan_oscillation_settings": { + "oscillation_command_topic": "test-topic", + "oscillation_state_topic": "test-topic#invalid", + }, + "fan_direction_settings": { + "direction_command_topic": "test-topic", + "direction_state_topic": "test-topic#invalid", + }, + }, + { + "state_topic": "invalid_subscribe_topic", + "fan_preset_mode_settings": "invalid_subscribe_topic", + "fan_speed_settings": "invalid_subscribe_topic", + "fan_oscillation_settings": "invalid_subscribe_topic", + "fan_direction_settings": "invalid_subscribe_topic", + }, + ), + ( + { + "command_topic": "test-topic", + "fan_speed_settings": { + "percentage_command_topic": "test-topic", + }, + "fan_preset_mode_settings": { + "preset_modes": ["None", "auto"], + "preset_mode_command_topic": "test-topic", + }, + "fan_oscillation_settings": { + "oscillation_command_topic": "test-topic", + }, + "fan_direction_settings": { + "direction_command_topic": "test-topic", + }, + }, + { + "fan_preset_mode_settings": "fan_preset_mode_reset_in_preset_modes_list", + }, + ), + ( + { + "command_topic": "test-topic", + "fan_speed_settings": { + "percentage_command_topic": "test-topic", + "speed_range_min": 100, + "speed_range_max": 10, + }, + "fan_preset_mode_settings": { + "preset_modes": ["eco", "auto"], + "preset_mode_command_topic": "test-topic", + }, + "fan_oscillation_settings": { + "oscillation_command_topic": "test-topic", + }, + "fan_direction_settings": { + "direction_command_topic": "test-topic", + }, + }, + { + "fan_speed_settings": "fan_speed_range_max_must_be_greater_than_speed_range_min", + }, + ), + ), + "Milk notifier Breezer", + ), ( MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, @@ -2758,7 +3038,15 @@ async def test_migrate_of_incompatible_config_entry( { "state_class": "measurement", }, - (), + ( + ( + { + "state_class": "measurement_angle", + "unit_of_measurement": "deg", + }, + {"unit_of_measurement": "invalid_uom_for_state_class"}, + ), + ), { "state_topic": "test-topic", }, @@ -2840,6 +3128,10 @@ async def test_migrate_of_incompatible_config_entry( ), ], ids=[ + "binary_sensor", + "button", + "cover", + "fan", "notify_with_entity_name", "notify_no_entity_name", "sensor_options", diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 74dc94de21e..ea1b7e186e2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -995,6 +995,32 @@ async def test_invalid_state_class( assert "expected SensorStateClass or one of" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "state_class": "measurement_angle", + "unit_of_measurement": "deg", + } + } + } + ], +) +async def test_invalid_state_class_with_unit_of_measurement( + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture +) -> None: + """Test state_class option with invalid unit of measurement.""" + assert await mqtt_mock_entry() + assert ( + "The unit of measurement 'deg' is not valid together with state class 'measurement_angle'" + in caplog.text + ) + + @pytest.mark.parametrize( ("hass_config", "error_logged"), [ @@ -1515,7 +1541,7 @@ async def test_cleanup_triggers_and_restoring_state( await mqtt_mock_entry() async_fire_mqtt_message(hass, "test-topic1", "100") state = hass.states.get("sensor.test1") - assert state.state == "38" # 100 °F -> 38 °C + assert round(float(state.state)) == 38 # 100 °F -> 38 °C async_fire_mqtt_message(hass, "test-topic2", "200") state = hass.states.get("sensor.test2") @@ -1527,14 +1553,14 @@ async def test_cleanup_triggers_and_restoring_state( await hass.async_block_till_done() state = hass.states.get("sensor.test1") - assert state.state == "38" # 100 °F -> 38 °C + assert round(float(state.state)) == 38 # 100 °F -> 38 °C state = hass.states.get("sensor.test2") assert state.state == STATE_UNAVAILABLE async_fire_mqtt_message(hass, "test-topic1", "80") state = hass.states.get("sensor.test1") - assert state.state == "27" # 80 °F -> 27 °C + assert round(float(state.state)) == 27 # 80 °F -> 27 °C async_fire_mqtt_message(hass, "test-topic2", "201") state = hass.states.get("sensor.test2") diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 87eb381db03..335bf9cb4da 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -211,10 +211,7 @@ async def test_value_template( assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "1.9.0" assert state.attributes.get("latest_version") == "1.9.0" - assert ( - state.attributes.get("entity_picture") - == "https://brands.home-assistant.io/_/mqtt/icon.png" - ) + assert state.attributes.get("entity_picture") is None async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"2.0.0"}') @@ -324,10 +321,7 @@ async def test_value_template_float( assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "1.9" assert state.attributes.get("latest_version") == "1.9" - assert ( - state.attributes.get("entity_picture") - == "https://brands.home-assistant.io/_/mqtt/icon.png" - ) + assert state.attributes.get("entity_picture") is None async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"2.0"}') @@ -949,9 +943,5 @@ async def test_entity_icon_and_entity_picture( domain = update.DOMAIN config = DEFAULT_CONFIG await help_test_entity_icon_and_entity_picture( - hass, - mqtt_mock_entry, - domain, - config, - default_entity_picture="https://brands.home-assistant.io/_/mqtt/icon.png", + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 6d7ef927c6e..a98ae82fbe1 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -19,7 +19,7 @@ from music_assistant_models.media_items import ( ) from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index e8978f17f86..58ce20da824 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -18,7 +18,8 @@ "pause", "set_members", "power", - "enqueue" + "enqueue", + "select_source" ], "elapsed_time": null, "elapsed_time_last_updated": 0, @@ -43,7 +44,32 @@ "hide_player_in_ui": ["when_unavailable"], "expose_to_ha": true, "can_group_with": ["00:00:00:00:00:02"], - "source_list": [] + "source_list": [ + { + "id": "00:00:00:00:00:01", + "name": "Music Assistant Queue", + "passive": false, + "can_play_pause": true, + "can_seek": true, + "can_next_previous": true + }, + { + "id": "spotify", + "name": "Spotify Connect", + "passive": true, + "can_play_pause": true, + "can_seek": true, + "can_next_previous": true + }, + { + "id": "linein", + "name": "Line-In", + "passive": false, + "can_play_pause": false, + "can_seek": false, + "can_next_previous": false + } + ] }, { "player_id": "00:00:00:00:00:02", diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index f561a5c3afb..d530406ff88 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -28,6 +28,7 @@ 'original_name': None, 'platform': 'music_assistant', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:02', @@ -54,6 +55,7 @@ 'media_duration': 300, 'media_position': 0, 'media_title': 'Test Track', + 'source': 'Spotify Connect', 'supported_features': , 'volume_level': 0.2, }), @@ -94,6 +96,7 @@ 'original_name': None, 'platform': 'music_assistant', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'test_group_player_1', @@ -142,6 +145,10 @@ }), 'area_id': None, 'capabilities': dict({ + 'source_list': list([ + 'Music Assistant Queue', + 'Line-In', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -165,7 +172,8 @@ 'original_name': None, 'platform': 'music_assistant', 'previous_unique_id': None, - 'supported_features': , + 'suggested_object_id': None, + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:01', 'unit_of_measurement': None, @@ -181,7 +189,11 @@ ]), 'icon': 'mdi:speaker', 'mass_player_type': 'player', - 'supported_features': , + 'source_list': list([ + 'Music Assistant Queue', + 'Line-In', + ]), + 'supported_features': , }), 'context': , 'entity_id': 'media_player.test_player_1', diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index ba8b1acdeac..0a469807de3 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.media_items import SearchResults import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.music_assistant.actions import ( SERVICE_GET_LIBRARY, diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 00ba6bc8093..eb1e64485c4 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -11,11 +11,12 @@ from music_assistant_models.enums import ( ) from music_assistant_models.media_items import Track import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, + ATTR_INPUT_SOURCE, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SEEK_POSITION, @@ -25,6 +26,7 @@ from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, + SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, MediaPlayerEntityFeature, ) @@ -620,6 +622,31 @@ async def test_media_player_get_queue_action( assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time")) +async def test_media_player_select_source_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity select source action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_INPUT_SOURCE: "Line-In", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/select_source", player_id=mass_player_id, source="linein" + ) + + async def test_media_player_supported_features( hass: HomeAssistant, music_assistant_client: MagicMock, @@ -652,6 +679,7 @@ async def test_media_player_supported_features( | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.SELECT_SOURCE ) assert state.attributes["supported_features"] == expected_features # remove power control capability from player, trigger subscription callback diff --git a/tests/components/myuplink/snapshots/test_binary_sensor.ambr b/tests/components/myuplink/snapshots/test_binary_sensor.ambr index 478c5a55b80..52b3f2314f8 100644 --- a/tests/components/myuplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/myuplink/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Alarm', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm', 'unique_id': '123456-7890-1234-has_alarm', @@ -75,6 +76,7 @@ 'original_name': 'Connectivity', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-connection_state', @@ -123,6 +125,7 @@ 'original_name': 'Connectivity', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-connection_state', @@ -171,6 +174,7 @@ 'original_name': 'Extern. adjust\xadment climate system 1', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'elect_add', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43161', @@ -218,6 +222,7 @@ 'original_name': 'Extern. adjust\xadment climate system 1', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'elect_add', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43161', @@ -265,6 +270,7 @@ 'original_name': 'Pump: Heating medium (GP1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49995', @@ -312,6 +318,7 @@ 'original_name': 'Pump: Heating medium (GP1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49995', diff --git a/tests/components/myuplink/snapshots/test_number.ambr b/tests/components/myuplink/snapshots/test_number.ambr index f2c89663879..f8a290f89e3 100644 --- a/tests/components/myuplink/snapshots/test_number.ambr +++ b/tests/components/myuplink/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Degree minutes', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'degree_minutes', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', @@ -89,6 +90,7 @@ 'original_name': 'Degree minutes', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'degree_minutes', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', @@ -146,6 +148,7 @@ 'original_name': 'Heating offset climate system 1', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47011', @@ -202,6 +205,7 @@ 'original_name': 'Heating offset climate system 1', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47011', @@ -258,6 +262,7 @@ 'original_name': 'Room sensor set point value heating climate system 1', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47398', @@ -314,6 +319,7 @@ 'original_name': 'Room sensor set point value heating climate system 1', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47398', @@ -370,6 +376,7 @@ 'original_name': 'start diff additional heat', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'degree_minutes', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072', @@ -427,6 +434,7 @@ 'original_name': 'start diff additional heat', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'degree_minutes', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072', diff --git a/tests/components/myuplink/snapshots/test_select.ambr b/tests/components/myuplink/snapshots/test_select.ambr index 032fd2ef455..08c4244d0f6 100644 --- a/tests/components/myuplink/snapshots/test_select.ambr +++ b/tests/components/myuplink/snapshots/test_select.ambr @@ -34,6 +34,7 @@ 'original_name': 'comfort mode', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47041', @@ -94,6 +95,7 @@ 'original_name': 'comfort mode', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47041', diff --git a/tests/components/myuplink/snapshots/test_sensor.ambr b/tests/components/myuplink/snapshots/test_sensor.ambr index f9249651208..06b2612da1b 100644 --- a/tests/components/myuplink/snapshots/test_sensor.ambr +++ b/tests/components/myuplink/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Average outdoor temp (BT1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40067', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Average outdoor temp (BT1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40067', @@ -127,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Calculated supply climate system 1', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43009', @@ -179,12 +191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Calculated supply climate system 1', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43009', @@ -231,12 +247,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Condenser (BT12)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40017', @@ -283,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Condenser (BT12)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40017', @@ -335,12 +359,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current (BE1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40079', @@ -387,12 +415,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current (BE1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40079', @@ -439,12 +471,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current (BE2)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40081', @@ -491,12 +527,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current (BE2)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40081', @@ -543,12 +583,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current (BE3)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40083', @@ -595,12 +639,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current (BE3)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40083', @@ -647,12 +695,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current compressor frequency', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-41778', @@ -699,12 +751,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current compressor frequency', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-41778', @@ -755,6 +811,7 @@ 'original_name': 'Current fan mode', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan_mode', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43108', @@ -802,6 +859,7 @@ 'original_name': 'Current fan mode', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan_mode', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43108', @@ -849,6 +907,7 @@ 'original_name': 'Current hot water mode', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43109', @@ -897,6 +956,7 @@ 'original_name': 'Current hot water mode', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43109', @@ -941,12 +1001,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current outd temp (BT1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40004', @@ -993,12 +1057,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current outd temp (BT1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40004', @@ -1049,6 +1117,7 @@ 'original_name': 'Decrease from reference value', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43125', @@ -1097,6 +1166,7 @@ 'original_name': 'Decrease from reference value', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43125', @@ -1150,6 +1220,7 @@ 'original_name': 'Defrosting time', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43066', @@ -1205,6 +1276,7 @@ 'original_name': 'Defrosting time', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43066', @@ -1255,6 +1327,7 @@ 'original_name': 'Degree minutes', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', @@ -1303,6 +1376,7 @@ 'original_name': 'Degree minutes', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', @@ -1351,6 +1425,7 @@ 'original_name': 'Desired humidity', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-42770', @@ -1399,6 +1474,7 @@ 'original_name': 'Desired humidity', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49633', @@ -1447,6 +1523,7 @@ 'original_name': 'Desired humidity', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-42770', @@ -1495,6 +1572,7 @@ 'original_name': 'Desired humidity', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49633', @@ -1539,12 +1617,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Discharge (BT14)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40018', @@ -1591,12 +1673,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Discharge (BT14)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40018', @@ -1643,12 +1729,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'dT Inverter - exh air (BT20)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43146', @@ -1695,12 +1785,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'dT Inverter - exh air (BT20)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43146', @@ -1747,12 +1841,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Evaporator (BT16)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40020', @@ -1799,12 +1897,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Evaporator (BT16)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40020', @@ -1851,12 +1953,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Exhaust air (BT20)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40025', @@ -1903,12 +2009,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Exhaust air (BT20)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40025', @@ -1955,12 +2065,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Extract air (BT21)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40026', @@ -2007,12 +2121,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Extract air (BT21)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40026', @@ -2063,6 +2181,7 @@ 'original_name': 'Heating medium pump speed (GP1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43437', @@ -2111,6 +2230,7 @@ 'original_name': 'Heating medium pump speed (GP1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43437', @@ -2155,12 +2275,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hot water: charge current value ((BT12 | BT63))', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43116', @@ -2207,12 +2331,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hot water: charge current value ((BT12 | BT63))', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43116', @@ -2259,12 +2387,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hot water: charge set point value', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43115', @@ -2311,12 +2443,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hot water: charge set point value', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43115', @@ -2363,12 +2499,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hot water charging (BT6)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40014', @@ -2415,12 +2555,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hot water charging (BT6)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40014', @@ -2467,12 +2611,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hot water top (BT7)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40013', @@ -2519,12 +2667,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hot water top (BT7)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40013', @@ -2585,6 +2737,7 @@ 'original_name': 'Int elec add heat', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'elect_add', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993', @@ -2652,6 +2805,7 @@ 'original_name': 'Int elec add heat', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'elect_add', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993', @@ -2709,6 +2863,7 @@ 'original_name': 'Int elec add heat raw', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'elect_add', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993-raw', @@ -2756,6 +2911,7 @@ 'original_name': 'Int elec add heat raw', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'elect_add', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993-raw', @@ -2799,12 +2955,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Inverter temperature', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43140', @@ -2851,12 +3011,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Inverter temperature', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43140', @@ -2903,12 +3067,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Liquid line (BT15)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40019', @@ -2955,12 +3123,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Liquid line (BT15)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40019', @@ -3007,12 +3179,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Max compressor frequency', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43123', @@ -3059,12 +3235,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Max compressor frequency', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43123', @@ -3111,12 +3291,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Min compressor frequency', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43122', @@ -3163,12 +3347,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Min compressor frequency', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43122', @@ -3215,12 +3403,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Oil temperature (BT29)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40146', @@ -3267,12 +3459,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Oil temperature (BT29)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40146', @@ -3319,12 +3515,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Oil temperature (EP15-BT29)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40145', @@ -3371,12 +3571,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Oil temperature (EP15-BT29)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40145', @@ -3437,6 +3641,7 @@ 'original_name': 'Priority', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'priority', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994', @@ -3504,6 +3709,7 @@ 'original_name': 'Priority', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'priority', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994', @@ -3561,6 +3767,7 @@ 'original_name': 'Prior\xadity raw', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'priority', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994-raw', @@ -3608,6 +3815,7 @@ 'original_name': 'Prior\xadity raw', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'priority', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994-raw', @@ -3655,6 +3863,7 @@ 'original_name': 'r start diff additional heat', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072r', @@ -3703,6 +3912,7 @@ 'original_name': 'r start diff additional heat', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072r', @@ -3747,12 +3957,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reference, air speed sensor', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'airflow', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43124', @@ -3799,12 +4013,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Reference, air speed sensor', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'airflow', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43124', @@ -3851,12 +4069,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Return line (BT3)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40012', @@ -3903,12 +4125,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Return line (BT3)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40012', @@ -3955,12 +4181,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Return line (BT62)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40048', @@ -4007,12 +4237,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Return line (BT62)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40048', @@ -4059,12 +4293,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Room temperature (BT50)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40033', @@ -4111,12 +4349,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Room temperature (BT50)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40033', @@ -4174,6 +4416,7 @@ 'original_name': 'Status compressor', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_compressor', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427', @@ -4235,6 +4478,7 @@ 'original_name': 'Status compressor', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_compressor', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427', @@ -4289,6 +4533,7 @@ 'original_name': 'Status com\xadpressor raw', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_compressor', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427-raw', @@ -4336,6 +4581,7 @@ 'original_name': 'Status com\xadpressor raw', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status_compressor', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427-raw', @@ -4379,12 +4625,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Suction gas (BT17)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40022', @@ -4431,12 +4681,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Suction gas (BT17)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40022', @@ -4483,12 +4737,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Supply line (BT2)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40008', @@ -4535,12 +4793,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Supply line (BT2)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40008', @@ -4587,12 +4849,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Supply line (BT61)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40047', @@ -4639,12 +4905,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Supply line (BT61)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40047', @@ -4695,6 +4965,7 @@ 'original_name': 'Time factor add heat', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43081', @@ -4743,6 +5014,7 @@ 'original_name': 'Time factor add heat', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43081', @@ -4791,6 +5063,7 @@ 'original_name': 'Value, air velocity sensor (BS1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40050', @@ -4839,6 +5112,7 @@ 'original_name': 'Value, air velocity sensor (BS1)', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40050', diff --git a/tests/components/myuplink/snapshots/test_switch.ambr b/tests/components/myuplink/snapshots/test_switch.ambr index 142d4caa455..4f8d690ada6 100644 --- a/tests/components/myuplink/snapshots/test_switch.ambr +++ b/tests/components/myuplink/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'In\xadcreased venti\xadlation', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boost_ventilation', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50005', @@ -74,6 +75,7 @@ 'original_name': 'In\xadcreased venti\xadlation', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boost_ventilation', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50005', @@ -121,6 +123,7 @@ 'original_name': 'Tempo\xadrary lux', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temporary_lux', 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50004', @@ -168,6 +171,7 @@ 'original_name': 'Tempo\xadrary lux', 'platform': 'myuplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temporary_lux', 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50004', diff --git a/tests/components/myuplink/test_binary_sensor.py b/tests/components/myuplink/test_binary_sensor.py index 160530bcdab..cf297a0a3f7 100644 --- a/tests/components/myuplink/test_binary_sensor.py +++ b/tests/components/myuplink/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/myuplink/test_diagnostics.py b/tests/components/myuplink/test_diagnostics.py index e0803eb76f0..1da81c5cf1f 100644 --- a/tests/components/myuplink/test_diagnostics.py +++ b/tests/components/myuplink/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the myuplink integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths from homeassistant.core import HomeAssistant diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index 320bf202024..891ba992772 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock from aiohttp import ClientConnectionError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.myuplink.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/myuplink/test_number.py b/tests/components/myuplink/test_number.py index ef7b1749782..a488ae3972c 100644 --- a/tests/components/myuplink/test_number.py +++ b/tests/components/myuplink/test_number.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/myuplink/test_select.py b/tests/components/myuplink/test_select.py index f1797ebe5ad..f19aff60d26 100644 --- a/tests/components/myuplink/test_select.py +++ b/tests/components/myuplink/test_select.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import ( ATTR_ENTITY_ID, diff --git a/tests/components/myuplink/test_sensor.py b/tests/components/myuplink/test_sensor.py index 98cdfc322da..9f0beebe995 100644 --- a/tests/components/myuplink/test_sensor.py +++ b/tests/components/myuplink/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/myuplink/test_switch.py b/tests/components/myuplink/test_switch.py index 82d381df7fc..628287b8fd8 100644 --- a/tests/components/myuplink/test_switch.py +++ b/tests/components/myuplink/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import ( ATTR_ENTITY_ID, diff --git a/tests/components/nam/snapshots/test_sensor.ambr b/tests/components/nam/snapshots/test_sensor.ambr index c6c32737a31..cc6bc9bc7b6 100644 --- a/tests/components/nam/snapshots/test_sensor.ambr +++ b/tests/components/nam/snapshots/test_sensor.ambr @@ -32,6 +32,7 @@ 'original_name': 'BH1750 illuminance', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bh1750_illuminance', 'unique_id': 'aa:bb:cc:dd:ee:ff-bh1750_illuminance', @@ -87,6 +88,7 @@ 'original_name': 'BME280 humidity', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bme280_humidity', 'unique_id': 'aa:bb:cc:dd:ee:ff-bme280_humidity', @@ -142,6 +144,7 @@ 'original_name': 'BME280 pressure', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bme280_pressure', 'unique_id': 'aa:bb:cc:dd:ee:ff-bme280_pressure', @@ -197,6 +200,7 @@ 'original_name': 'BME280 temperature', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bme280_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff-bme280_temperature', @@ -252,6 +256,7 @@ 'original_name': 'BMP180 pressure', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bmp180_pressure', 'unique_id': 'aa:bb:cc:dd:ee:ff-bmp180_pressure', @@ -307,6 +312,7 @@ 'original_name': 'BMP180 temperature', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bmp180_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff-bmp180_temperature', @@ -362,6 +368,7 @@ 'original_name': 'BMP280 pressure', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bmp280_pressure', 'unique_id': 'aa:bb:cc:dd:ee:ff-bmp280_pressure', @@ -417,6 +424,7 @@ 'original_name': 'BMP280 temperature', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bmp280_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff-bmp280_temperature', @@ -472,6 +480,7 @@ 'original_name': 'DHT22 humidity', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dht22_humidity', 'unique_id': 'aa:bb:cc:dd:ee:ff-dht22_humidity', @@ -527,6 +536,7 @@ 'original_name': 'DHT22 temperature', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dht22_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff-dht22_temperature', @@ -582,6 +592,7 @@ 'original_name': 'DS18B20 temperature', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ds18b20_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff-ds18b20_temperature', @@ -637,6 +648,7 @@ 'original_name': 'HECA humidity', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heca_humidity', 'unique_id': 'aa:bb:cc:dd:ee:ff-heca_humidity', @@ -692,6 +704,7 @@ 'original_name': 'HECA temperature', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heca_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff-heca_temperature', @@ -742,6 +755,7 @@ 'original_name': 'Last restart', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_restart', 'unique_id': 'aa:bb:cc:dd:ee:ff-uptime', @@ -795,6 +809,7 @@ 'original_name': 'MH-Z14A carbon dioxide', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mhz14a_carbon_dioxide', 'unique_id': 'aa:bb:cc:dd:ee:ff-mhz14a_carbon_dioxide', @@ -845,6 +860,7 @@ 'original_name': 'PMSx003 common air quality index', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pmsx003_caqi', 'unique_id': 'aa:bb:cc:dd:ee:ff-pms_caqi', @@ -900,6 +916,7 @@ 'original_name': 'PMSx003 common air quality index level', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pmsx003_caqi_level', 'unique_id': 'aa:bb:cc:dd:ee:ff-pms_caqi_level', @@ -960,6 +977,7 @@ 'original_name': 'PMSx003 PM1', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pmsx003_pm1', 'unique_id': 'aa:bb:cc:dd:ee:ff-pms_p0', @@ -1015,6 +1033,7 @@ 'original_name': 'PMSx003 PM10', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pmsx003_pm10', 'unique_id': 'aa:bb:cc:dd:ee:ff-pms_p1', @@ -1070,6 +1089,7 @@ 'original_name': 'PMSx003 PM2.5', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pmsx003_pm25', 'unique_id': 'aa:bb:cc:dd:ee:ff-pms_p2', @@ -1120,6 +1140,7 @@ 'original_name': 'SDS011 common air quality index', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sds011_caqi', 'unique_id': 'aa:bb:cc:dd:ee:ff-sds011_caqi', @@ -1175,6 +1196,7 @@ 'original_name': 'SDS011 common air quality index level', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sds011_caqi_level', 'unique_id': 'aa:bb:cc:dd:ee:ff-sds011_caqi_level', @@ -1235,6 +1257,7 @@ 'original_name': 'SDS011 PM10', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sds011_pm10', 'unique_id': 'aa:bb:cc:dd:ee:ff-sds011_p1', @@ -1290,6 +1313,7 @@ 'original_name': 'SDS011 PM2.5', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sds011_pm25', 'unique_id': 'aa:bb:cc:dd:ee:ff-sds011_p2', @@ -1345,6 +1369,7 @@ 'original_name': 'SHT3X humidity', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sht3x_humidity', 'unique_id': 'aa:bb:cc:dd:ee:ff-sht3x_humidity', @@ -1400,6 +1425,7 @@ 'original_name': 'SHT3X temperature', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sht3x_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff-sht3x_temperature', @@ -1455,6 +1481,7 @@ 'original_name': 'Signal strength', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff-signal', @@ -1505,6 +1532,7 @@ 'original_name': 'SPS30 common air quality index', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sps30_caqi', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_caqi', @@ -1560,6 +1588,7 @@ 'original_name': 'SPS30 common air quality index level', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sps30_caqi_level', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_caqi_level', @@ -1620,6 +1649,7 @@ 'original_name': 'SPS30 PM1', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sps30_pm1', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_p0', @@ -1675,6 +1705,7 @@ 'original_name': 'SPS30 PM10', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sps30_pm10', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_p1', @@ -1730,6 +1761,7 @@ 'original_name': 'SPS30 PM2.5', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sps30_pm25', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_p2', @@ -1785,6 +1817,7 @@ 'original_name': 'SPS30 PM4', 'platform': 'nam', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sps30_pm4', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_p4', diff --git a/tests/components/nam/test_diagnostics.py b/tests/components/nam/test_diagnostics.py index 7ed49a37e0a..b29e5e834b2 100644 --- a/tests/components/nam/test_diagnostics.py +++ b/tests/components/nam/test_diagnostics.py @@ -1,6 +1,6 @@ """Test NAM diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index 6924af48f01..40cabfb49ae 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch from freezegun.api import FrozenDateTimeFactory from nettigo_air_monitor import ApiError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tenacity import RetryError from homeassistant.components.nam.const import DEFAULT_UPDATE_INTERVAL, DOMAIN diff --git a/tests/components/nanoleaf/__init__.py b/tests/components/nanoleaf/__init__.py index ee614fad173..0e6d571e320 100644 --- a/tests/components/nanoleaf/__init__.py +++ b/tests/components/nanoleaf/__init__.py @@ -1 +1,13 @@ """Tests for the Nanoleaf integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set 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/nanoleaf/conftest.py b/tests/components/nanoleaf/conftest.py new file mode 100644 index 00000000000..5dae7727eec --- /dev/null +++ b/tests/components/nanoleaf/conftest.py @@ -0,0 +1,49 @@ +"""Common fixtures for Nanoleaf tests.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.nanoleaf import DOMAIN +from homeassistant.const import CONF_HOST, CONF_TOKEN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a Nanoleaf config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "10.0.0.10", + CONF_TOKEN: "1234567890abcdef", + }, + ) + + +@pytest.fixture +async def mock_nanoleaf() -> AsyncGenerator[AsyncMock]: + """Mock a Nanoleaf device.""" + with patch( + "homeassistant.components.nanoleaf.Nanoleaf", autospec=True + ) as mock_nanoleaf: + client = mock_nanoleaf.return_value + client.model = "NO_TOUCH" + client.host = "10.0.0.10" + client.serial_no = "ABCDEF123456" + client.color_temperature_max = 4500 + client.color_temperature_min = 1200 + client.is_on = False + client.brightness = 50 + client.color_temperature = 2700 + client.hue = 120 + client.saturation = 50 + client.color_mode = "hs" + client.effect = "Rainbow" + client.effects_list = ["Rainbow", "Sunset", "Nemo"] + client.firmware_version = "4.0.0" + client.name = "Nanoleaf" + client.manufacturer = "Nanoleaf" + yield client diff --git a/tests/components/nanoleaf/snapshots/test_light.ambr b/tests/components/nanoleaf/snapshots/test_light.ambr new file mode 100644 index 00000000000..19d857026dd --- /dev/null +++ b/tests/components/nanoleaf/snapshots/test_light.ambr @@ -0,0 +1,85 @@ +# serializer version: 1 +# name: test_entities[light.nanoleaf-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'Rainbow', + 'Sunset', + 'Nemo', + ]), + 'max_color_temp_kelvin': 4500, + 'max_mireds': 833, + 'min_color_temp_kelvin': 1200, + 'min_mireds': 222, + '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.nanoleaf', + 'has_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': 'nanoleaf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'light', + 'unique_id': 'ABCDEF123456', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.nanoleaf-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'effect': None, + 'effect_list': list([ + 'Rainbow', + 'Sunset', + 'Nemo', + ]), + 'friendly_name': 'Nanoleaf', + 'hs_color': None, + 'max_color_temp_kelvin': 4500, + 'max_mireds': 833, + 'min_color_temp_kelvin': 1200, + 'min_mireds': 222, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.nanoleaf', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/nanoleaf/test_light.py b/tests/components/nanoleaf/test_light.py new file mode 100644 index 00000000000..3260c2e2609 --- /dev/null +++ b/tests/components/nanoleaf/test_light.py @@ -0,0 +1,68 @@ +"""Tests for the Nanoleaf light platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ATTR_EFFECT_LIST, DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_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 + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_nanoleaf: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.nanoleaf.PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +async def test_turning_on_or_off_writes_state( + hass: HomeAssistant, + mock_nanoleaf: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, +) -> None: + """Test turning on or off the light writes the state.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.nanoleaf").attributes[ATTR_EFFECT_LIST] == [ + "Rainbow", + "Sunset", + "Nemo", + ] + + mock_nanoleaf.effects_list = ["Rainbow", "Sunset", "Nemo", "Something Else"] + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + { + ATTR_ENTITY_ID: "light.nanoleaf", + }, + blocking=True, + ) + assert hass.states.get("light.nanoleaf").attributes[ATTR_EFFECT_LIST] == [ + "Rainbow", + "Sunset", + "Nemo", + "Something Else", + ] diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 3e7dbd3f223..c0579c99a62 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -826,7 +826,6 @@ async def test_camera_multiple_streams( assert cam is not None assert cam.state == CameraState.STREAMING # Prefer WebRTC over RTSP/HLS - assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC client = await hass_ws_client(hass) assert await async_frontend_stream_types(client, "camera.my_camera") == [ StreamType.WEB_RTC @@ -905,7 +904,6 @@ async def test_webrtc_refresh_expired_stream( cam = hass.states.get("camera.my_camera") assert cam is not None assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC client = await hass_ws_client(hass) assert await async_frontend_stream_types(client, "camera.my_camera") == [ StreamType.WEB_RTC diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 0e6ec290841..67364aff412 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -995,6 +995,10 @@ async def test_dhcp_discovery( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "oauth_discovery" + + result = await oauth.async_configure(result, {}) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "create_cloud_project" result = await oauth.async_configure(result, {}) @@ -1002,6 +1006,24 @@ async def test_dhcp_discovery( assert result.get("reason") == "missing_credentials" +@pytest.mark.parametrize( + ("nest_test_config", "sdm_managed_topic", "device_access_project_id"), + [(TEST_CONFIG_APP_CREDS, True, "project-id-2")], +) +async def test_dhcp_discovery_already_setup( + hass: HomeAssistant, oauth: OAuthFixture, setup_platform +) -> None: + """Exercise discovery dhcp with existing config entry.""" + await setup_platform() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=FAKE_DHCP_DATA, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_dhcp_discovery_with_creds( hass: HomeAssistant, @@ -1015,6 +1037,10 @@ async def test_dhcp_discovery_with_creds( ) await hass.async_block_till_done() assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "oauth_discovery" + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "cloud_project" result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) diff --git a/tests/components/nest/test_diagnostics.py b/tests/components/nest/test_diagnostics.py index a072394a43d..74249a71a8b 100644 --- a/tests/components/nest/test_diagnostics.py +++ b/tests/components/nest/test_diagnostics.py @@ -4,7 +4,7 @@ from unittest.mock import patch from google_nest_sdm.exceptions import SubscriberException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.nest.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 9110f8c724f..06c56aa7e22 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -6,7 +6,7 @@ import json from typing import Any from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.webhook import async_handle_webhook from homeassistant.const import Platform diff --git a/tests/components/netatmo/snapshots/test_binary_sensor.ambr b/tests/components/netatmo/snapshots/test_binary_sensor.ambr index 3066c999655..0cf44637a77 100644 --- a/tests/components/netatmo/snapshots/test_binary_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:26:68:92-reachable', @@ -78,6 +79,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:26:69:0c-reachable', @@ -129,6 +131,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:25:cf:a8-reachable', @@ -180,6 +183,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:26:65:14-reachable', @@ -231,6 +235,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:3e:c5:46-reachable', @@ -282,6 +287,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:80:7e:18-reachable', @@ -331,6 +337,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:80:44:92-reachable', @@ -380,6 +387,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:80:bb:26-reachable', @@ -431,6 +439,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:03:1b:e4-reachable', @@ -480,6 +489,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:80:1c:42-reachable', @@ -529,6 +539,7 @@ 'original_name': 'Connectivity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:80:c1:ea-reachable', diff --git a/tests/components/netatmo/snapshots/test_button.ambr b/tests/components/netatmo/snapshots/test_button.ambr index 086403c3b69..e43d58ee962 100644 --- a/tests/components/netatmo/snapshots/test_button.ambr +++ b/tests/components/netatmo/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Preferred position', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'preferred_position', 'unique_id': '0009999993-DeviceType.NBO-preferred_position', @@ -75,6 +76,7 @@ 'original_name': 'Preferred position', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'preferred_position', 'unique_id': '0009999992-DeviceType.NBR-preferred_position', diff --git a/tests/components/netatmo/snapshots/test_camera.ambr b/tests/components/netatmo/snapshots/test_camera.ambr index 9bd10ed9b5f..0b9bb4e948d 100644 --- a/tests/components/netatmo/snapshots/test_camera.ambr +++ b/tests/components/netatmo/snapshots/test_camera.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '12:34:56:10:b9:0e-DeviceType.NOC', @@ -42,7 +43,6 @@ 'brand': 'Netatmo', 'entity_picture': '/api/camera_proxy/camera.front?token=1caab5c3b3', 'friendly_name': 'Front', - 'frontend_stream_type': , 'id': '12:34:56:10:b9:0e', 'is_local': False, 'light_state': None, @@ -89,6 +89,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '12:34:56:00:f1:62-DeviceType.NACamera', @@ -104,7 +105,6 @@ 'brand': 'Netatmo', 'entity_picture': '/api/camera_proxy/camera.hall?token=1caab5c3b3', 'friendly_name': 'Hall', - 'frontend_stream_type': , 'id': '12:34:56:00:f1:62', 'is_local': True, 'light_state': None, @@ -151,6 +151,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '12:34:56:10:f1:66-DeviceType.NDB', diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr index 506e0fb5590..22a50213306 100644 --- a/tests/components/netatmo/snapshots/test_climate.ambr +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -41,6 +41,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '222452125-DeviceType.OTM', @@ -117,6 +118,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '2940411577-DeviceType.NRV', @@ -199,6 +201,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '1002003001-DeviceType.BNS', @@ -280,6 +283,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '2833524037-DeviceType.NRV', @@ -363,6 +367,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '2746182631-DeviceType.NATherm1', diff --git a/tests/components/netatmo/snapshots/test_cover.ambr b/tests/components/netatmo/snapshots/test_cover.ambr index 46aafb32e8e..1f83fcba615 100644 --- a/tests/components/netatmo/snapshots/test_cover.ambr +++ b/tests/components/netatmo/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '0009999993-DeviceType.NBO', @@ -78,6 +79,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '0009999992-DeviceType.NBR', diff --git a/tests/components/netatmo/snapshots/test_fan.ambr b/tests/components/netatmo/snapshots/test_fan.ambr index f850f7ada3b..51136218734 100644 --- a/tests/components/netatmo/snapshots/test_fan.ambr +++ b/tests/components/netatmo/snapshots/test_fan.ambr @@ -32,6 +32,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '12:34:56:00:01:01:01:b1-DeviceType.NLLF', diff --git a/tests/components/netatmo/snapshots/test_light.ambr b/tests/components/netatmo/snapshots/test_light.ambr index cc7da6e8712..21fdc11842a 100644 --- a/tests/components/netatmo/snapshots/test_light.ambr +++ b/tests/components/netatmo/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:01:01:01:a1-light', @@ -88,6 +89,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:10:b9:0e-light', @@ -144,6 +146,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:11:22:33:00:11:45:fe-light', diff --git a/tests/components/netatmo/snapshots/test_select.ambr b/tests/components/netatmo/snapshots/test_select.ambr index d98d9adb87f..f7c6303cead 100644 --- a/tests/components/netatmo/snapshots/test_select.ambr +++ b/tests/components/netatmo/snapshots/test_select.ambr @@ -32,6 +32,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '91763b24c43d3e344f424e8b-schedule-select', diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index 8b974027116..c0431a6449c 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -35,6 +35,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure', 'unique_id': '12:34:56:26:68:92-pressure', @@ -90,6 +91,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2', 'unique_id': '12:34:56:26:68:92-co2', @@ -151,6 +153,7 @@ 'original_name': 'Health index', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'health_idx', 'unique_id': '12:34:56:26:68:92-health_idx', @@ -211,6 +214,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '12:34:56:26:68:92-humidity', @@ -260,12 +264,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Noise', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'noise', 'unique_id': '12:34:56:26:68:92-noise', @@ -319,6 +327,7 @@ 'original_name': 'Pressure trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure_trend', 'unique_id': '12:34:56:26:68:92-pressure_trend', @@ -369,6 +378,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:26:68:92-reachable', @@ -424,6 +434,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '12:34:56:26:68:92-temperature', @@ -477,6 +488,7 @@ 'original_name': 'Temperature trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_trend', 'unique_id': '12:34:56:26:68:92-temp_trend', @@ -527,6 +539,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': '12:34:56:26:68:92-wifi_status', @@ -585,6 +598,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure', 'unique_id': '12:34:56:26:69:0c-pressure', @@ -638,6 +652,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2', 'unique_id': '12:34:56:26:69:0c-co2', @@ -697,6 +712,7 @@ 'original_name': 'Health index', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'health_idx', 'unique_id': '12:34:56:26:69:0c-health_idx', @@ -755,6 +771,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '12:34:56:26:69:0c-humidity', @@ -802,12 +819,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Noise', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'noise', 'unique_id': '12:34:56:26:69:0c-noise', @@ -859,6 +880,7 @@ 'original_name': 'Pressure trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure_trend', 'unique_id': '12:34:56:26:69:0c-pressure_trend', @@ -907,6 +929,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:26:69:0c-reachable', @@ -962,6 +985,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '12:34:56:26:69:0c-temperature', @@ -1013,6 +1037,7 @@ 'original_name': 'Temperature trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_trend', 'unique_id': '12:34:56:26:69:0c-temp_trend', @@ -1061,6 +1086,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': '12:34:56:26:69:0c-wifi_status', @@ -1113,6 +1139,7 @@ 'original_name': 'Battery', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '222452125-12:34:56:20:f5:8c-battery', @@ -1164,6 +1191,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e#8-12:34:56:00:16:0e#8-reachable', @@ -1212,6 +1240,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:00:a1:4c:da-12:34:56:00:00:a1:4c:da-reachable', @@ -1256,12 +1285,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:00:a1:4c:da-12:34:56:00:00:a1:4c:da-power', @@ -1315,6 +1348,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1002003001-1002003001-humidity', @@ -1366,6 +1400,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e-12:34:56:00:16:0e-reachable', @@ -1414,6 +1449,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e#6-12:34:56:00:16:0e#6-reachable', @@ -1470,6 +1506,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-avg-pressure', @@ -1525,6 +1562,7 @@ 'original_name': 'Gust angle', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gust_angle', 'unique_id': 'Home-avg-gustangle_value', @@ -1574,12 +1612,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Gust strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gust_strength', 'unique_id': 'Home-avg-guststrength', @@ -1635,6 +1677,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-avg-humidity', @@ -1684,12 +1727,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-avg-rain', @@ -1748,6 +1795,7 @@ 'original_name': 'Precipitation last hour', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_rain_1', 'unique_id': 'Home-avg-sum_rain_1', @@ -1797,12 +1845,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation today', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_rain_24', 'unique_id': 'Home-avg-sum_rain_24', @@ -1861,6 +1913,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-avg-temperature', @@ -1916,6 +1969,7 @@ 'original_name': 'Wind direction', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-avg-windangle_value', @@ -1965,12 +2019,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-avg-windstrength', @@ -2032,6 +2090,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-max-pressure', @@ -2087,6 +2146,7 @@ 'original_name': 'Gust angle', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gust_angle', 'unique_id': 'Home-max-gustangle_value', @@ -2136,12 +2196,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Gust strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gust_strength', 'unique_id': 'Home-max-guststrength', @@ -2197,6 +2261,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-max-humidity', @@ -2246,12 +2311,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-max-rain', @@ -2310,6 +2379,7 @@ 'original_name': 'Precipitation last hour', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_rain_1', 'unique_id': 'Home-max-sum_rain_1', @@ -2359,12 +2429,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation today', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_rain_24', 'unique_id': 'Home-max-sum_rain_24', @@ -2423,6 +2497,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-max-temperature', @@ -2478,6 +2553,7 @@ 'original_name': 'Wind direction', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-max-windangle_value', @@ -2527,12 +2603,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-max-windstrength', @@ -2594,6 +2674,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-min-pressure', @@ -2649,6 +2730,7 @@ 'original_name': 'Gust angle', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gust_angle', 'unique_id': 'Home-min-gustangle_value', @@ -2698,12 +2780,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Gust strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gust_strength', 'unique_id': 'Home-min-guststrength', @@ -2759,6 +2845,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-min-humidity', @@ -2808,12 +2895,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-min-rain', @@ -2872,6 +2963,7 @@ 'original_name': 'Precipitation last hour', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_rain_1', 'unique_id': 'Home-min-sum_rain_1', @@ -2921,12 +3013,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation today', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_rain_24', 'unique_id': 'Home-min-sum_rain_24', @@ -2985,6 +3081,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-min-temperature', @@ -3040,6 +3137,7 @@ 'original_name': 'Wind direction', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-min-windangle_value', @@ -3089,12 +3187,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Home-min-windstrength', @@ -3148,6 +3250,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e#7-12:34:56:00:16:0e#7-reachable', @@ -3204,6 +3307,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure', 'unique_id': '12:34:56:25:cf:a8-pressure', @@ -3259,6 +3363,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2', 'unique_id': '12:34:56:25:cf:a8-co2', @@ -3320,6 +3425,7 @@ 'original_name': 'Health index', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'health_idx', 'unique_id': '12:34:56:25:cf:a8-health_idx', @@ -3380,6 +3486,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '12:34:56:25:cf:a8-humidity', @@ -3429,12 +3536,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Noise', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'noise', 'unique_id': '12:34:56:25:cf:a8-noise', @@ -3488,6 +3599,7 @@ 'original_name': 'Pressure trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure_trend', 'unique_id': '12:34:56:25:cf:a8-pressure_trend', @@ -3538,6 +3650,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:25:cf:a8-reachable', @@ -3593,6 +3706,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '12:34:56:25:cf:a8-temperature', @@ -3646,6 +3760,7 @@ 'original_name': 'Temperature trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_trend', 'unique_id': '12:34:56:25:cf:a8-temp_trend', @@ -3696,6 +3811,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': '12:34:56:25:cf:a8-wifi_status', @@ -3746,6 +3862,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e#0-12:34:56:00:16:0e#0-reachable', @@ -3794,6 +3911,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e#1-12:34:56:00:16:0e#1-reachable', @@ -3842,6 +3960,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e#2-12:34:56:00:16:0e#2-reachable', @@ -3890,6 +4009,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e#3-12:34:56:00:16:0e#3-reachable', @@ -3938,6 +4058,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e#4-12:34:56:00:16:0e#4-reachable', @@ -3994,6 +4115,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure', 'unique_id': '12:34:56:26:65:14-pressure', @@ -4049,6 +4171,7 @@ 'original_name': 'Battery', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2746182631-12:34:56:00:01:ae-battery', @@ -4102,6 +4225,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2', 'unique_id': '12:34:56:26:65:14-co2', @@ -4163,6 +4287,7 @@ 'original_name': 'Health index', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'health_idx', 'unique_id': '12:34:56:26:65:14-health_idx', @@ -4223,6 +4348,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '12:34:56:26:65:14-humidity', @@ -4272,12 +4398,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Noise', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'noise', 'unique_id': '12:34:56:26:65:14-noise', @@ -4331,6 +4461,7 @@ 'original_name': 'Pressure trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure_trend', 'unique_id': '12:34:56:26:65:14-pressure_trend', @@ -4381,6 +4512,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:26:65:14-reachable', @@ -4436,6 +4568,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '12:34:56:26:65:14-temperature', @@ -4489,6 +4622,7 @@ 'original_name': 'Temperature trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_trend', 'unique_id': '12:34:56:26:65:14-temp_trend', @@ -4539,6 +4673,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': '12:34:56:26:65:14-wifi_status', @@ -4597,6 +4732,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure', 'unique_id': '12:34:56:3e:c5:46-pressure', @@ -4652,6 +4788,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2', 'unique_id': '12:34:56:3e:c5:46-co2', @@ -4713,6 +4850,7 @@ 'original_name': 'Health index', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'health_idx', 'unique_id': '12:34:56:3e:c5:46-health_idx', @@ -4773,6 +4911,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '12:34:56:3e:c5:46-humidity', @@ -4822,12 +4961,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Noise', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'noise', 'unique_id': '12:34:56:3e:c5:46-noise', @@ -4881,6 +5024,7 @@ 'original_name': 'Pressure trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure_trend', 'unique_id': '12:34:56:3e:c5:46-pressure_trend', @@ -4931,6 +5075,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:3e:c5:46-reachable', @@ -4986,6 +5131,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '12:34:56:3e:c5:46-temperature', @@ -5039,6 +5185,7 @@ 'original_name': 'Temperature trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_trend', 'unique_id': '12:34:56:3e:c5:46-temp_trend', @@ -5089,6 +5236,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': '12:34:56:3e:c5:46-wifi_status', @@ -5139,6 +5287,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:80:00:12:ac:f2-12:34:56:80:00:12:ac:f2-reachable', @@ -5183,12 +5332,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:80:00:12:ac:f2-12:34:56:80:00:12:ac:f2-power', @@ -5240,6 +5393,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:00:16:0e#5-12:34:56:00:16:0e#5-reachable', @@ -5290,6 +5444,7 @@ 'original_name': 'Battery', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2833524037-12:34:56:03:a5:54-battery', @@ -5343,6 +5498,7 @@ 'original_name': 'Battery', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2940411577-12:34:56:03:a0:ac-battery', @@ -5402,6 +5558,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure', 'unique_id': '12:34:56:80:bb:26-pressure', @@ -5457,6 +5614,7 @@ 'original_name': 'Battery', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', 'unique_id': '12:34:56:80:7e:18-battery_percent', @@ -5510,6 +5668,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2', 'unique_id': '12:34:56:80:7e:18-co2', @@ -5563,6 +5722,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '12:34:56:80:7e:18-humidity', @@ -5614,6 +5774,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:80:7e:18-reachable', @@ -5662,6 +5823,7 @@ 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rf_strength', 'unique_id': '12:34:56:80:7e:18-rf_status', @@ -5715,6 +5877,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '12:34:56:80:7e:18-temperature', @@ -5766,6 +5929,7 @@ 'original_name': 'Temperature trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_trend', 'unique_id': '12:34:56:80:7e:18-temp_trend', @@ -5816,6 +5980,7 @@ 'original_name': 'Battery', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', 'unique_id': '12:34:56:80:44:92-battery_percent', @@ -5869,6 +6034,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2', 'unique_id': '12:34:56:80:44:92-co2', @@ -5922,6 +6088,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '12:34:56:80:44:92-humidity', @@ -5973,6 +6140,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:80:44:92-reachable', @@ -6021,6 +6189,7 @@ 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rf_strength', 'unique_id': '12:34:56:80:44:92-rf_status', @@ -6074,6 +6243,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '12:34:56:80:44:92-temperature', @@ -6125,6 +6295,7 @@ 'original_name': 'Temperature trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_trend', 'unique_id': '12:34:56:80:44:92-temp_trend', @@ -6175,6 +6346,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'co2', 'unique_id': '12:34:56:80:bb:26-co2', @@ -6230,6 +6402,7 @@ 'original_name': 'Battery', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', 'unique_id': '12:34:56:03:1b:e4-battery_percent', @@ -6283,6 +6456,7 @@ 'original_name': 'Gust angle', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gust_angle', 'unique_id': '12:34:56:03:1b:e4-gustangle_value', @@ -6345,6 +6519,7 @@ 'original_name': 'Gust direction', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gust_direction', 'unique_id': '12:34:56:03:1b:e4-gustangle', @@ -6400,12 +6575,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Gust strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gust_strength', 'unique_id': '12:34:56:03:1b:e4-guststrength', @@ -6457,6 +6636,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:03:1b:e4-reachable', @@ -6505,6 +6685,7 @@ 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rf_strength', 'unique_id': '12:34:56:03:1b:e4-rf_status', @@ -6555,6 +6736,7 @@ 'original_name': 'Wind angle', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_angle', 'unique_id': '12:34:56:03:1b:e4-windangle_value', @@ -6617,6 +6799,7 @@ 'original_name': 'Wind direction', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_direction', 'unique_id': '12:34:56:03:1b:e4-windangle', @@ -6672,12 +6855,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wind speed', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_strength', 'unique_id': '12:34:56:03:1b:e4-windstrength', @@ -6731,6 +6918,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '12:34:56:80:bb:26-humidity', @@ -6780,12 +6968,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Noise', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'noise', 'unique_id': '12:34:56:80:bb:26-noise', @@ -6841,6 +7033,7 @@ 'original_name': 'Battery', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', 'unique_id': '12:34:56:80:1c:42-battery_percent', @@ -6894,6 +7087,7 @@ 'original_name': 'Humidity', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '12:34:56:80:1c:42-humidity', @@ -6945,6 +7139,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:80:1c:42-reachable', @@ -6993,6 +7188,7 @@ 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rf_strength', 'unique_id': '12:34:56:80:1c:42-rf_status', @@ -7046,6 +7242,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '12:34:56:80:1c:42-temperature', @@ -7097,6 +7294,7 @@ 'original_name': 'Temperature trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_trend', 'unique_id': '12:34:56:80:1c:42-temp_trend', @@ -7145,6 +7343,7 @@ 'original_name': 'Pressure trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pressure_trend', 'unique_id': '12:34:56:80:bb:26-pressure_trend', @@ -7197,6 +7396,7 @@ 'original_name': 'Battery', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', 'unique_id': '12:34:56:80:c1:ea-battery_percent', @@ -7244,12 +7444,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rain', 'unique_id': '12:34:56:80:c1:ea-rain', @@ -7306,6 +7510,7 @@ 'original_name': 'Precipitation last hour', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_rain_1', 'unique_id': '12:34:56:80:c1:ea-sum_rain_1', @@ -7353,12 +7558,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation today', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sum_rain_24', 'unique_id': '12:34:56:80:c1:ea-sum_rain_24', @@ -7410,6 +7619,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:80:c1:ea-reachable', @@ -7458,6 +7668,7 @@ 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rf_strength', 'unique_id': '12:34:56:80:c1:ea-rf_status', @@ -7506,6 +7717,7 @@ 'original_name': 'Reachability', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reachable', 'unique_id': '12:34:56:80:bb:26-reachable', @@ -7561,6 +7773,7 @@ 'original_name': 'Temperature', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '12:34:56:80:bb:26-temperature', @@ -7614,6 +7827,7 @@ 'original_name': 'Temperature trend', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_trend', 'unique_id': '12:34:56:80:bb:26-temp_trend', @@ -7664,6 +7878,7 @@ 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', 'unique_id': '12:34:56:80:bb:26-wifi_status', diff --git a/tests/components/netatmo/snapshots/test_switch.ambr b/tests/components/netatmo/snapshots/test_switch.ambr index f44cbcd22a5..3dd2d5658ac 100644 --- a/tests/components/netatmo/snapshots/test_switch.ambr +++ b/tests/components/netatmo/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'netatmo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12:34:56:80:00:12:ac:f2-DeviceType.NLP', diff --git a/tests/components/netatmo/test_binary_sensor.py b/tests/components/netatmo/test_binary_sensor.py index 7b841ba204e..91d2b3ad63b 100644 --- a/tests/components/netatmo/test_binary_sensor.py +++ b/tests/components/netatmo/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py index bffecf7d83a..d526f508624 100644 --- a/tests/components/netatmo/test_button.py +++ b/tests/components/netatmo/test_button.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 32f20544043..706cf887539 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch import pyatmo import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import camera from homeassistant.components.camera import CameraState diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 45216e415a5..f3532c999e7 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from voluptuous.error import MultipleInvalid from homeassistant.components.climate import ( diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py index 9368a564afb..3aa67395cec 100644 --- a/tests/components/netatmo/test_cover.py +++ b/tests/components/netatmo/test_cover.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_POSITION, diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 7a0bf11c652..dadec4a1eb2 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths from homeassistant.core import HomeAssistant diff --git a/tests/components/netatmo/test_fan.py b/tests/components/netatmo/test_fan.py index 3dbc8b3a6f5..e80d3ae76fd 100644 --- a/tests/components/netatmo/test_fan.py +++ b/tests/components/netatmo/test_fan.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PRESET_MODE, diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index c1a687c6fa8..18d255ec6ee 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch import aiohttp from pyatmo.const import ALL_SCOPES import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import cloud from homeassistant.components.netatmo import DOMAIN diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 0932395b8ec..16a3ac2aaeb 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 458115f8f5c..6b9eb6f4451 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index e9e1ff4739e..95776d21f6a 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.netatmo import sensor from homeassistant.const import Platform diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index 837f6201b1e..fd7b09daa4f 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/nexia/fixtures/sensors_xl1050_house.json b/tests/components/nexia/fixtures/sensors_xl1050_house.json new file mode 100644 index 00000000000..4293b92c6cf --- /dev/null +++ b/tests/components/nexia/fixtures/sensors_xl1050_house.json @@ -0,0 +1,1096 @@ +{ + "success": true, + "error": null, + "result": { + "id": 123456, + "name": "My Home", + "third_party_integrations": [], + "latitude": null, + "longitude": null, + "time_zone": "America/New_York", + "dealer_opt_in": true, + "room_iq_enabled": true, + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/houses/123456" + }, + "edit": [ + { + "href": "https://www.mynexia.com/mobile/houses/123456/edit", + "method": "GET" + } + ], + "child": [ + { + "href": "https://www.mynexia.com/mobile/houses/123456/devices", + "type": "application/vnd.nexia.collection+json", + "data": { + "items": [ + { + "id": 5378307, + "name": "Center", + "name_editable": true, + "features": [ + { + "name": "advanced_info", + "items": [ + { + "type": "label_value", + "label": "Model", + "value": "XL1050" + }, + { + "type": "label_value", + "label": "AUID", + "value": "0295CB84" + }, + { + "type": "label_value", + "label": "Firmware Build Number", + "value": "1726826973" + }, + { + "type": "label_value", + "label": "Firmware Build Date", + "value": "2024-09-20 10:09:33 UTC" + }, + { + "type": "label_value", + "label": "Firmware Version", + "value": "5.11.1" + } + ] + }, + { + "name": "thermostat", + "scale": "f", + "temperature": 69, + "device_identifier": "XxlZone-85034552", + "status": "System Idle", + "status_icon": null, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/setpoints" + } + }, + "setpoint_delta": 3, + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 69, + "system_status": "System Idle", + "operating_state": "idle" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "dealer_contact_info", + "has_dealer_identifier": true, + "actions": { + "request_current_dealer_info": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/dealers/7043919191" + }, + "request_dealers_by_zip": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/dealers/5378307/search" + } + } + }, + { + "name": "thermostat_mode", + "label": "System Mode", + "value": "HEAT", + "display_value": "Heating", + "options": [ + { + "id": "thermostat_mode", + "label": "System Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "run_schedule", + "display_value": "Run Schedule", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/run_mode" + } + } + }, + { + "name": "room_iq_sensors", + "sensors": [ + { + "id": 17687546, + "name": "Center", + "icon": { + "name": "room_iq_onboard", + "modifiers": [] + }, + "type": "thermostat", + "serial_number": "NativeIDTUniqueID", + "weight": 0.5, + "temperature": 68, + "temperature_valid": true, + "humidity": 32, + "humidity_valid": true, + "has_online": false, + "has_battery": false + }, + { + "id": 17687549, + "name": "Upstairs", + "icon": { + "name": "room_iq_wireless", + "modifiers": [] + }, + "type": "930", + "serial_number": "2410R5C53X", + "weight": 0.5, + "temperature": 69, + "temperature_valid": true, + "humidity": 32, + "humidity_valid": true, + "has_online": true, + "connected": true, + "has_battery": true, + "battery_level": 95, + "battery_low": false, + "battery_valid": true + } + ], + "should_show": true, + "actions": { + "request_current_state": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/request_current_sensor_state" + }, + "update_active_sensors": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/update_active_sensors" + } + } + }, + { + "name": "preset_setpoints", + "presets": { + "1": { + "cool": 78, + "heat": 70 + }, + "2": { + "cool": 85, + "heat": 62 + }, + "3": { + "cool": 82, + "heat": 62 + } + } + }, + { + "name": "thermostat_fan_mode", + "label": "Fan Mode", + "options": [ + { + "id": "thermostat_fan_mode", + "label": "Fan Mode", + "value": "thermostat_fan_mode", + "header": true + }, + { + "value": "auto", + "label": "Auto" + }, + { + "value": "on", + "label": "On" + }, + { + "value": "circulate", + "label": "Circulate" + } + ], + "value": "circulate", + "display_value": "Circulate", + "status_icon": { + "name": "thermostat_fan_off", + "modifiers": [] + }, + "actions": { + "update_thermostat_fan_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/fan_mode" + } + } + }, + { + "name": "thermostat_default_fan_mode", + "value": "circulate", + "actions": { + "update_thermostat_default_fan_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/fan_mode" + } + } + }, + { + "name": "gen_2_app", + "is_supported": false, + "validation_failures": [ + "Thermostat has wireless sensors.", + "Unauthorized to use Gen 2 App." + ] + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1.0, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=TraneXl1050-5378307\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=TraneXl1050-5378307", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=TraneXl1050-5378307", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=TraneXl1050-5378307", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + }, + { + "name": "runtime_history", + "actions": { + "get_runtime_history": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/runtime_history/TraneXl1050-5378307?report_type=daily" + }, + "get_monthly_runtime_history": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/runtime_history/TraneXl1050-5378307?report_type=monthly" + } + } + } + ], + "icon": [ + { + "name": "thermostat", + "modifiers": ["temperature-69"] + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307" + }, + "nexia:history": { + "href": "https://www.mynexia.com/mobile/houses/123456/events?device_id=5378307" + }, + "filter_events": { + "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e16684f6-b1e3-4e25-b006-e4d599dab2e9" + } + }, + "last_updated_at": "2025-01-06T17:45:09.000-05:00", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/preset_selected" + } + } + }, + { + "type": "system_mode", + "title": "System Mode", + "current_value": "HEAT", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "run_schedule", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/scheduling_enabled" + } + } + }, + { + "type": "fan_mode", + "title": "Fan Mode", + "current_value": "circulate", + "options": [ + { + "value": "auto", + "label": "Auto" + }, + { + "value": "on", + "label": "On" + }, + { + "value": "circulate", + "label": "Circulate" + } + ], + "labels": ["Auto", "On", "Circulate"], + "values": ["auto", "on", "circulate"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/fan_mode" + } + } + }, + { + "type": "fan_circulation_time", + "title": "Fan Circulation Time", + "current_value": 10, + "options": [ + { + "value": 10, + "label": "10 minutes" + }, + { + "value": 15, + "label": "15 minutes" + }, + { + "value": 20, + "label": "20 minutes" + }, + { + "value": 25, + "label": "25 minutes" + }, + { + "value": 30, + "label": "30 minutes" + }, + { + "value": 35, + "label": "35 minutes" + }, + { + "value": 40, + "label": "40 minutes" + }, + { + "value": 45, + "label": "45 minutes" + }, + { + "value": 50, + "label": "50 minutes" + }, + { + "value": 55, + "label": "55 minutes" + } + ], + "labels": [ + "10 minutes", + "15 minutes", + "20 minutes", + "25 minutes", + "30 minutes", + "35 minutes", + "40 minutes", + "45 minutes", + "50 minutes", + "55 minutes" + ], + "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/fan_circulation_time" + } + } + }, + { + "type": "air_cleaner_mode", + "title": "Air Cleaner Mode", + "current_value": "auto", + "options": [ + { + "value": "auto", + "label": "Auto" + }, + { + "value": "quick", + "label": "Quick" + }, + { + "value": "allergy", + "label": "Allergy" + } + ], + "labels": ["Auto", "Quick", "Allergy"], + "values": ["auto", "quick", "allergy"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/air_cleaner_mode" + } + } + }, + { + "type": "dehumidify", + "title": "Cooling Dehumidify Set Point", + "current_value": 0.5, + "options": [ + { + "value": 0.35, + "label": "35%" + }, + { + "value": 0.4, + "label": "40%" + }, + { + "value": 0.45, + "label": "45%" + }, + { + "value": 0.5, + "label": "50%" + }, + { + "value": 0.55, + "label": "55%" + }, + { + "value": 0.6, + "label": "60%" + }, + { + "value": 0.65, + "label": "65%" + } + ], + "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], + "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/dehumidify" + } + } + }, + { + "type": "emergency_heat", + "title": "Emergency Heat", + "current_value": false, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/emergency_heat" + } + } + }, + { + "type": "scale", + "title": "Temperature Scale", + "current_value": "f", + "options": [ + { + "value": "f", + "label": "F" + }, + { + "value": "c", + "label": "C" + } + ], + "labels": ["F", "C"], + "values": ["f", "c"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/scale" + } + } + } + ], + "status_secondary": null, + "status_tertiary": null, + "type": "xxl_thermostat", + "has_outdoor_temperature": true, + "outdoor_temperature": "39", + "has_indoor_humidity": true, + "connected": true, + "indoor_humidity": "33", + "system_status": "System Idle", + "delta": 3, + "manufacturer": "AmericanStandard", + "country_code": "US", + "state_code": "NC", + "zones": [ + { + "type": "xxl_zone", + "id": 85034552, + "name": "NativeZone", + "current_zone_mode": "HEAT", + "temperature": 69, + "setpoints": { + "heat": 69, + "cool": null + }, + "operating_state": "", + "heating_setpoint": 69, + "cooling_setpoint": null, + "zone_status": "", + "settings": [], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-69"] + }, + "features": [ + { + "name": "thermostat", + "scale": "f", + "temperature": 69, + "device_identifier": "XxlZone-85034552", + "status": "", + "status_icon": null, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/setpoints" + } + }, + "setpoint_delta": 3, + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 69, + "system_status": "System Idle" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "dealer_contact_info", + "has_dealer_identifier": true, + "actions": { + "request_current_dealer_info": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/dealers/7043919191" + }, + "request_dealers_by_zip": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/dealers/5378307/search" + } + } + }, + { + "name": "thermostat_mode", + "label": "System Mode", + "value": "HEAT", + "display_value": "Heating", + "options": [ + { + "id": "thermostat_mode", + "label": "System Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "run_schedule", + "display_value": "Run Schedule", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/run_mode" + } + } + }, + { + "name": "room_iq_sensors", + "sensors": [ + { + "id": 17687546, + "name": "Center", + "icon": { + "name": "room_iq_onboard", + "modifiers": [] + }, + "type": "thermostat", + "serial_number": "NativeIDTUniqueID", + "weight": 0.5, + "temperature": 68, + "temperature_valid": true, + "humidity": 32, + "humidity_valid": true, + "has_online": false, + "has_battery": false + }, + { + "id": 17687549, + "name": "Upstairs", + "icon": { + "name": "room_iq_wireless", + "modifiers": [] + }, + "type": "930", + "serial_number": "2410R5C53X", + "weight": 0.5, + "temperature": 69, + "temperature_valid": true, + "humidity": 32, + "humidity_valid": true, + "has_online": true, + "connected": true, + "has_battery": true, + "battery_level": 95, + "battery_low": false, + "battery_valid": true + } + ], + "should_show": true, + "actions": { + "request_current_state": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/request_current_sensor_state" + }, + "update_active_sensors": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/update_active_sensors" + } + } + }, + { + "name": "preset_setpoints", + "presets": { + "1": { + "cool": 78, + "heat": 70 + }, + "2": { + "cool": 85, + "heat": 62 + }, + "3": { + "cool": 82, + "heat": 62 + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1.0, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-85034552\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-85034552", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-85034552", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-85034552", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552" + } + } + } + ], + "generic_input_sensors": [] + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/houses/123456/devices" + }, + "template": { + "data": { + "title": null, + "fields": [], + "_links": { + "child-schema": [ + { + "data": { + "label": "Connect New Device", + "icon": { + "name": "new_device", + "modifiers": [] + }, + "_links": { + "next": { + "href": "https://www.mynexia.com/mobile/houses/123456/enrollables_schema" + } + } + } + }, + { + "data": { + "label": "Create Group", + "icon": { + "name": "create_group", + "modifiers": [] + }, + "_links": { + "next": { + "href": "https://www.mynexia.com/mobile/houses/123456/groups/new" + } + } + } + } + ] + } + } + } + }, + "item_type": "application/vnd.nexia.device+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/houses/123456/automations", + "type": "application/vnd.nexia.collection+json", + "data": { + "items": [ + { + "id": 4995413, + "name": "My First Automation", + "enabled": false, + "settings": [], + "triggers": [], + "description": "Click the Edit button to set up automation for your devices.", + "icon": [], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/automations/4995413" + }, + "edit": { + "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=4995413", + "method": "POST" + }, + "nexia:history": { + "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=4995413" + }, + "filter_events": { + "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=d827e212-3055-4835-8bda-333d26f05c9d" + } + } + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/houses/123456/automations" + }, + "template": { + "href": "https://www.mynexia.com/mobile/houses/123456/automation_edit_buffers", + "method": "POST" + } + }, + "item_type": "application/vnd.nexia.automation+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/houses/123456/modes", + "type": "application/vnd.nexia.collection+json", + "data": { + "items": [ + { + "id": 6631129, + "name": "Day", + "current_mode": false, + "icon": "home.png", + "settings": [], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/modes/6631129" + } + } + }, + { + "id": 6631132, + "name": "Night", + "current_mode": true, + "icon": "home.png", + "settings": [], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/modes/6631132" + } + } + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/houses/123456/modes" + } + }, + "item_type": "application/vnd.nexia.mode+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/houses/123456/events/collection", + "type": "application/vnd.nexia.collection+json", + "data": { + "item_type": "application/vnd.nexia.event+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/houses/123456/videos/collection", + "type": "application/vnd.nexia.collection+json", + "data": { + "item_type": "application/vnd.nexia.video+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/choices", + "type": "application/vnd.nexia.collection+json", + "data": { + "items": [], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/choices" + } + }, + "item_type": "application/vnd.nexia.choice+json" + } + } + ], + "feature_code_url": [ + { + "href": "https://www.mynexia.com/mobile/houses/123456/feature_code", + "method": "POST" + } + ], + "remove_zwave_device": [ + { + "href": "https://www.mynexia.com/mobile/houses/123456/remove_zwave_device", + "cancel_href": "https://www.mynexia.com/mobile/houses/123456/cancel_remove_zwave_device", + "method": "POST", + "timeout": 240, + "display": true + } + ] + } + } +} diff --git a/tests/components/nexia/test_diagnostics.py b/tests/components/nexia/test_diagnostics.py index ff9696d1567..fc3a8d5ee98 100644 --- a/tests/components/nexia/test_diagnostics.py +++ b/tests/components/nexia/test_diagnostics.py @@ -1,6 +1,6 @@ """Test august diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/nexia/test_sensor.py b/tests/components/nexia/test_sensor.py index ec9ed256617..1a3fc5618ff 100644 --- a/tests/components/nexia/test_sensor.py +++ b/tests/components/nexia/test_sensor.py @@ -12,7 +12,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: await async_init_integration(hass) state = hass.states.get("sensor.nick_office_temperature") - assert state.state == "23" + assert round(float(state.state)) == 23 expected_attributes = { "attribution": "Data provided by Trane Technologies", @@ -65,7 +65,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: ) state = hass.states.get("sensor.master_suite_current_compressor_speed") - assert state.state == "69.0" + assert round(float(state.state)) == 69 expected_attributes = { "attribution": "Data provided by Trane Technologies", @@ -79,7 +79,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: ) state = hass.states.get("sensor.master_suite_outdoor_temperature") - assert state.state == "30.6" + assert round(float(state.state), 1) == 30.6 expected_attributes = { "attribution": "Data provided by Trane Technologies", diff --git a/tests/components/nexia/test_switch.py b/tests/components/nexia/test_switch.py index 821d939bac5..e532201f01e 100644 --- a/tests/components/nexia/test_switch.py +++ b/tests/components/nexia/test_switch.py @@ -1,12 +1,74 @@ """The switch tests for the nexia platform.""" -from homeassistant.const import STATE_ON +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_STOP, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant from .util import async_init_integration +from tests.common import async_fire_time_changed + async def test_hold_switch(hass: HomeAssistant) -> None: """Test creation of the hold switch.""" await async_init_integration(hass) assert hass.states.get("switch.nick_office_hold").state == STATE_ON + + +async def test_nexia_sensor_switch( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test NexiaRoomIQSensorSwitch.""" + await async_init_integration(hass, house_fixture="nexia/sensors_xl1050_house.json") + sw1_id = f"{Platform.SWITCH}.center_nativezone_include_center" + sw1 = {ATTR_ENTITY_ID: sw1_id} + sw2_id = f"{Platform.SWITCH}.center_nativezone_include_upstairs" + sw2 = {ATTR_ENTITY_ID: sw2_id} + + # Switch starts out on. + assert (entity_state := hass.states.get(sw1_id)) is not None + assert entity_state.state == STATE_ON + + # Turn switch off. + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_OFF, sw1, blocking=True) + assert hass.states.get(sw1_id).state == STATE_OFF + + # Turn switch back on. + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_ON, sw1, blocking=True) + assert hass.states.get(sw1_id).state == STATE_ON + + # The other switch also starts out on. + assert (entity_state := hass.states.get(sw2_id)) is not None + assert entity_state.state == STATE_ON + + # Turn both switches off, an invalid combination. + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_OFF, sw1, blocking=True) + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_OFF, sw2, blocking=True) + assert hass.states.get(sw1_id).state == STATE_OFF + assert hass.states.get(sw2_id).state == STATE_OFF + + # Wait for switches to revert to device status. + freezer.tick(6) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sw1_id).state == STATE_ON + assert hass.states.get(sw2_id).state == STATE_ON + + # Turn switch off. + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_OFF, sw2, blocking=True) + assert hass.states.get(sw2_id).state == STATE_OFF + + # Exercise shutdown path. + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert hass.states.get(sw2_id).state == STATE_ON diff --git a/tests/components/nexia/util.py b/tests/components/nexia/util.py index 1104ffad63d..d9f0f59b719 100644 --- a/tests/components/nexia/util.py +++ b/tests/components/nexia/util.py @@ -17,10 +17,11 @@ async def async_init_integration( hass: HomeAssistant, skip_setup: bool = False, exception: Exception | None = None, + *, + house_fixture="nexia/mobile_houses_123456.json", ) -> MockConfigEntry: """Set up the nexia integration in Home Assistant.""" - house_fixture = "nexia/mobile_houses_123456.json" session_fixture = "nexia/session_123456.json" sign_in_fixture = "nexia/sign_in.json" set_fan_speed_fixture = "nexia/set_fan_speed_2293892.json" diff --git a/tests/components/nextbus/conftest.py b/tests/components/nextbus/conftest.py index 3f687989313..9891f6ffa49 100644 --- a/tests/components/nextbus/conftest.py +++ b/tests/components/nextbus/conftest.py @@ -137,6 +137,13 @@ def mock_nextbus_lists( def mock_nextbus() -> Generator[MagicMock]: """Create a mock py_nextbus module.""" with patch("homeassistant.components.nextbus.coordinator.NextBusClient") as client: + instance = client.return_value + + # Set some mocked rate limit values + instance.rate_limit = 450 + instance.rate_limit_remaining = 225 + instance.rate_limit_percent = 50.0 + yield client diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index 04140a17c4f..eacab5cd5c4 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the nexbus sensor component.""" from copy import deepcopy +from datetime import datetime, timedelta from unittest.mock import MagicMock from urllib.error import HTTPError @@ -122,6 +123,57 @@ async def test_verify_no_upcoming( assert state.state == "unknown" +async def test_verify_throttle( + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Verify that the sensor coordinator is throttled correctly.""" + + # Set rate limit past threshold, should be ignored for first request + mock_client = mock_nextbus.return_value + mock_client.rate_limit_percent = 99.0 + mock_client.rate_limit_reset = datetime.now() + timedelta(seconds=30) + + # Do a request with the initial config and get predictions + await assert_setup_sensor(hass, CONFIG_BASIC) + + # Validate the predictions are present + state = hass.states.get(SENSOR_ID) + assert state is not None + assert state.state == "2019-03-28T21:09:31+00:00" + assert state.attributes["agency"] == VALID_AGENCY + assert state.attributes["route"] == VALID_ROUTE_TITLE + assert state.attributes["stop"] == VALID_STOP_TITLE + assert state.attributes["upcoming"] == "1, 2, 3, 10" + + # Update the predictions mock to return a different result + mock_nextbus_predictions.return_value = NO_UPCOMING + + # Move time forward and bump the rate limit reset time + mock_client.rate_limit_reset = freezer.tick(31) + timedelta(seconds=30) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify that the sensor state is unchanged + state = hass.states.get(SENSOR_ID) + assert state is not None + assert state.state == "2019-03-28T21:09:31+00:00" + + # Move time forward past the rate limit reset time + freezer.tick(31) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify that the sensor state is updated with the new predictions + state = hass.states.get(SENSOR_ID) + assert state is not None + assert state.attributes["upcoming"] == "No upcoming predictions" + assert state.state == "unknown" + + async def test_unload_entry( hass: HomeAssistant, mock_nextbus: MagicMock, diff --git a/tests/components/nextcloud/snapshots/test_binary_sensor.ambr b/tests/components/nextcloud/snapshots/test_binary_sensor.ambr index 578659d411d..1037147469f 100644 --- a/tests/components/nextcloud/snapshots/test_binary_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Avatars enabled', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_enable_avatars', 'unique_id': '1234567890abcdef#system_enable_avatars', @@ -74,6 +75,7 @@ 'original_name': 'Debug enabled', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_debug', 'unique_id': '1234567890abcdef#system_debug', @@ -121,6 +123,7 @@ 'original_name': 'Filelocking enabled', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_filelocking_enabled', 'unique_id': '1234567890abcdef#system_filelocking.enabled', @@ -168,6 +171,7 @@ 'original_name': 'JIT active', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_jit_on', 'unique_id': '1234567890abcdef#jit_on', @@ -215,6 +219,7 @@ 'original_name': 'JIT enabled', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_jit_enabled', 'unique_id': '1234567890abcdef#jit_enabled', @@ -262,6 +267,7 @@ 'original_name': 'Previews enabled', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_enable_previews', 'unique_id': '1234567890abcdef#system_enable_previews', diff --git a/tests/components/nextcloud/snapshots/test_sensor.ambr b/tests/components/nextcloud/snapshots/test_sensor.ambr index e6154841a28..e425716b213 100644 --- a/tests/components/nextcloud/snapshots/test_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Amount of active users last 5 minutes', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_activeusers_last5minutes', 'unique_id': '1234567890abcdef#activeUsers_last5minutes', @@ -79,6 +80,7 @@ 'original_name': 'Amount of active users last day', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_activeusers_last24hours', 'unique_id': '1234567890abcdef#activeUsers_last24hours', @@ -129,6 +131,7 @@ 'original_name': 'Amount of active users last hour', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_activeusers_last1hour', 'unique_id': '1234567890abcdef#activeUsers_last1hour', @@ -179,6 +182,7 @@ 'original_name': 'Amount of files', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_storage_num_files', 'unique_id': '1234567890abcdef#storage_num_files', @@ -229,6 +233,7 @@ 'original_name': 'Amount of group shares', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_shares_num_shares_groups', 'unique_id': '1234567890abcdef#shares_num_shares_groups', @@ -279,6 +284,7 @@ 'original_name': 'Amount of link shares', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_shares_num_shares_link', 'unique_id': '1234567890abcdef#shares_num_shares_link', @@ -329,6 +335,7 @@ 'original_name': 'Amount of local storages', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_storage_num_storages_local', 'unique_id': '1234567890abcdef#storage_num_storages_local', @@ -379,6 +386,7 @@ 'original_name': 'Amount of mail shares', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_shares_num_shares_mail', 'unique_id': '1234567890abcdef#shares_num_shares_mail', @@ -429,6 +437,7 @@ 'original_name': 'Amount of other storages', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_storage_num_storages_other', 'unique_id': '1234567890abcdef#storage_num_storages_other', @@ -479,6 +488,7 @@ 'original_name': 'Amount of passwordless link shares', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_shares_num_shares_link_no_password', 'unique_id': '1234567890abcdef#shares_num_shares_link_no_password', @@ -529,6 +539,7 @@ 'original_name': 'Amount of room shares', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_shares_num_shares_room', 'unique_id': '1234567890abcdef#shares_num_shares_room', @@ -579,6 +590,7 @@ 'original_name': 'Amount of shares', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_shares_num_shares', 'unique_id': '1234567890abcdef#shares_num_shares', @@ -629,6 +641,7 @@ 'original_name': 'Amount of shares received', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_shares_num_fed_shares_received', 'unique_id': '1234567890abcdef#shares_num_fed_shares_received', @@ -679,6 +692,7 @@ 'original_name': 'Amount of shares sent', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_shares_num_fed_shares_sent', 'unique_id': '1234567890abcdef#shares_num_fed_shares_sent', @@ -729,6 +743,7 @@ 'original_name': 'Amount of storages', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_storage_num_storages', 'unique_id': '1234567890abcdef#storage_num_storages', @@ -779,6 +794,7 @@ 'original_name': 'Amount of storages at home', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_storage_num_storages_home', 'unique_id': '1234567890abcdef#storage_num_storages_home', @@ -829,6 +845,7 @@ 'original_name': 'Amount of user', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_storage_num_users', 'unique_id': '1234567890abcdef#storage_num_users', @@ -879,6 +896,7 @@ 'original_name': 'Amount of user shares', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_shares_num_shares_user', 'unique_id': '1234567890abcdef#shares_num_shares_user', @@ -929,6 +947,7 @@ 'original_name': 'Apps installed', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_apps_num_installed', 'unique_id': '1234567890abcdef#system_apps_num_installed', @@ -979,6 +998,7 @@ 'original_name': 'Cache expunges', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_expunges', 'unique_id': '1234567890abcdef#cache_expunges', @@ -1027,6 +1047,7 @@ 'original_name': 'Cache memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_memory_type', 'unique_id': '1234567890abcdef#cache_memory_type', @@ -1080,6 +1101,7 @@ 'original_name': 'Cache memory size', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_mem_size', 'unique_id': '1234567890abcdef#cache_mem_size', @@ -1131,6 +1153,7 @@ 'original_name': 'Cache number of entries', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_num_entries', 'unique_id': '1234567890abcdef#cache_num_entries', @@ -1181,6 +1204,7 @@ 'original_name': 'Cache number of hits', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_num_hits', 'unique_id': '1234567890abcdef#cache_num_hits', @@ -1231,6 +1255,7 @@ 'original_name': 'Cache number of inserts', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_num_inserts', 'unique_id': '1234567890abcdef#cache_num_inserts', @@ -1281,6 +1306,7 @@ 'original_name': 'Cache number of misses', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_num_misses', 'unique_id': '1234567890abcdef#cache_num_misses', @@ -1331,6 +1357,7 @@ 'original_name': 'Cache number of slots', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_num_slots', 'unique_id': '1234567890abcdef#cache_num_slots', @@ -1379,6 +1406,7 @@ 'original_name': 'Cache start time', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_start_time', 'unique_id': '1234567890abcdef#cache_start_time', @@ -1427,6 +1455,7 @@ 'original_name': 'Cache TTL', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_cache_ttl', 'unique_id': '1234567890abcdef#cache_ttl', @@ -1477,6 +1506,7 @@ 'original_name': 'CPU load last 15 minutes', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_cpuload_15', 'unique_id': '1234567890abcdef#system_cpuload_15', @@ -1528,6 +1558,7 @@ 'original_name': 'CPU load last 1 minute', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_cpuload_1', 'unique_id': '1234567890abcdef#system_cpuload_1', @@ -1579,6 +1610,7 @@ 'original_name': 'CPU load last 5 minutes', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_cpuload_5', 'unique_id': '1234567890abcdef#system_cpuload_5', @@ -1633,6 +1665,7 @@ 'original_name': 'Database size', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_database_size', 'unique_id': '1234567890abcdef#database_size', @@ -1682,6 +1715,7 @@ 'original_name': 'Database type', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_database_type', 'unique_id': '1234567890abcdef#database_type', @@ -1729,6 +1763,7 @@ 'original_name': 'Database version', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_database_version', 'unique_id': '1234567890abcdef#database_version', @@ -1782,6 +1817,7 @@ 'original_name': 'Free memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_mem_free', 'unique_id': '1234567890abcdef#system_mem_free', @@ -1837,6 +1873,7 @@ 'original_name': 'Free space', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_freespace', 'unique_id': '1234567890abcdef#system_freespace', @@ -1892,6 +1929,7 @@ 'original_name': 'Free swap memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_swap_free', 'unique_id': '1234567890abcdef#system_swap_free', @@ -1947,6 +1985,7 @@ 'original_name': 'Interned buffer size', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_interned_strings_usage_buffer_size', 'unique_id': '1234567890abcdef#interned_strings_usage_buffer_size', @@ -2002,6 +2041,7 @@ 'original_name': 'Interned free memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_interned_strings_usage_free_memory', 'unique_id': '1234567890abcdef#interned_strings_usage_free_memory', @@ -2053,6 +2093,7 @@ 'original_name': 'Interned number of strings', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_interned_strings_usage_number_of_strings', 'unique_id': '1234567890abcdef#interned_strings_usage_number_of_strings', @@ -2107,6 +2148,7 @@ 'original_name': 'Interned used memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_interned_strings_usage_used_memory', 'unique_id': '1234567890abcdef#interned_strings_usage_used_memory', @@ -2162,6 +2204,7 @@ 'original_name': 'JIT buffer free', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_jit_buffer_free', 'unique_id': '1234567890abcdef#jit_buffer_free', @@ -2217,6 +2260,7 @@ 'original_name': 'JIT buffer size', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_jit_buffer_size', 'unique_id': '1234567890abcdef#jit_buffer_size', @@ -2266,6 +2310,7 @@ 'original_name': 'JIT kind', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_jit_kind', 'unique_id': '1234567890abcdef#jit_kind', @@ -2313,6 +2358,7 @@ 'original_name': 'JIT opt flags', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_jit_opt_flags', 'unique_id': '1234567890abcdef#jit_opt_flags', @@ -2360,6 +2406,7 @@ 'original_name': 'JIT opt level', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_jit_opt_level', 'unique_id': '1234567890abcdef#jit_opt_level', @@ -2409,6 +2456,7 @@ 'original_name': 'Opcache blacklist miss ratio', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_blacklist_miss_ratio', 'unique_id': '1234567890abcdef#opcache_statistics_blacklist_miss_ratio', @@ -2460,6 +2508,7 @@ 'original_name': 'Opcache blacklist misses', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_blacklist_misses', 'unique_id': '1234567890abcdef#opcache_statistics_blacklist_misses', @@ -2510,6 +2559,7 @@ 'original_name': 'Opcache cached keys', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_num_cached_keys', 'unique_id': '1234567890abcdef#opcache_statistics_num_cached_keys', @@ -2560,6 +2610,7 @@ 'original_name': 'Opcache cached scripts', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_num_cached_scripts', 'unique_id': '1234567890abcdef#opcache_statistics_num_cached_scripts', @@ -2611,6 +2662,7 @@ 'original_name': 'Opcache current wasted percentage', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_server_php_opcache_memory_usage_current_wasted_percentage', 'unique_id': '1234567890abcdef#server_php_opcache_memory_usage_current_wasted_percentage', @@ -2665,6 +2717,7 @@ 'original_name': 'Opcache free memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_server_php_opcache_memory_usage_free_memory', 'unique_id': '1234567890abcdef#server_php_opcache_memory_usage_free_memory', @@ -2716,6 +2769,7 @@ 'original_name': 'Opcache hash restarts', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_hash_restarts', 'unique_id': '1234567890abcdef#opcache_statistics_hash_restarts', @@ -2767,6 +2821,7 @@ 'original_name': 'Opcache hit rate', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_opcache_hit_rate', 'unique_id': '1234567890abcdef#opcache_statistics_opcache_hit_rate', @@ -2817,6 +2872,7 @@ 'original_name': 'Opcache hits', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_hits', 'unique_id': '1234567890abcdef#opcache_statistics_hits', @@ -2865,6 +2921,7 @@ 'original_name': 'Opcache last restart time', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_last_restart_time', 'unique_id': '1234567890abcdef#opcache_statistics_last_restart_time', @@ -2915,6 +2972,7 @@ 'original_name': 'Opcache manual restarts', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_manual_restarts', 'unique_id': '1234567890abcdef#opcache_statistics_manual_restarts', @@ -2965,6 +3023,7 @@ 'original_name': 'Opcache max cached keys', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_max_cached_keys', 'unique_id': '1234567890abcdef#opcache_statistics_max_cached_keys', @@ -3015,6 +3074,7 @@ 'original_name': 'Opcache misses', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_misses', 'unique_id': '1234567890abcdef#opcache_statistics_misses', @@ -3065,6 +3125,7 @@ 'original_name': 'Opcache out of memory restarts', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_oom_restarts', 'unique_id': '1234567890abcdef#opcache_statistics_oom_restarts', @@ -3113,6 +3174,7 @@ 'original_name': 'Opcache start time', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_opcache_statistics_start_time', 'unique_id': '1234567890abcdef#opcache_statistics_start_time', @@ -3167,6 +3229,7 @@ 'original_name': 'Opcache used memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_server_php_opcache_memory_usage_used_memory', 'unique_id': '1234567890abcdef#server_php_opcache_memory_usage_used_memory', @@ -3222,6 +3285,7 @@ 'original_name': 'Opcache wasted memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_server_php_opcache_memory_usage_wasted_memory', 'unique_id': '1234567890abcdef#server_php_opcache_memory_usage_wasted_memory', @@ -3265,12 +3329,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'PHP max execution time', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_server_php_max_execution_time', 'unique_id': '1234567890abcdef#server_php_max_execution_time', @@ -3326,6 +3394,7 @@ 'original_name': 'PHP memory limit', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_server_php_memory_limit', 'unique_id': '1234567890abcdef#server_php_memory_limit', @@ -3381,6 +3450,7 @@ 'original_name': 'PHP upload maximum filesize', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_server_php_upload_max_filesize', 'unique_id': '1234567890abcdef#server_php_upload_max_filesize', @@ -3430,6 +3500,7 @@ 'original_name': 'PHP version', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_server_php_version', 'unique_id': '1234567890abcdef#server_php_version', @@ -3483,6 +3554,7 @@ 'original_name': 'SMA available memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_sma_avail_mem', 'unique_id': '1234567890abcdef#sma_avail_mem', @@ -3534,6 +3606,7 @@ 'original_name': 'SMA number of segments', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_sma_num_seg', 'unique_id': '1234567890abcdef#sma_num_seg', @@ -3588,6 +3661,7 @@ 'original_name': 'SMA segment size', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_sma_seg_size', 'unique_id': '1234567890abcdef#sma_seg_size', @@ -3637,6 +3711,7 @@ 'original_name': 'System memcache distributed', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_memcache_distributed', 'unique_id': '1234567890abcdef#system_memcache.distributed', @@ -3684,6 +3759,7 @@ 'original_name': 'System memcache local', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_memcache_local', 'unique_id': '1234567890abcdef#system_memcache.local', @@ -3731,6 +3807,7 @@ 'original_name': 'System memcache locking', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_memcache_locking', 'unique_id': '1234567890abcdef#system_memcache.locking', @@ -3778,6 +3855,7 @@ 'original_name': 'System theme', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_theme', 'unique_id': '1234567890abcdef#system_theme', @@ -3825,6 +3903,7 @@ 'original_name': 'System version', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_version', 'unique_id': '1234567890abcdef#system_version', @@ -3878,6 +3957,7 @@ 'original_name': 'Total memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_mem_total', 'unique_id': '1234567890abcdef#system_mem_total', @@ -3933,6 +4013,7 @@ 'original_name': 'Total swap memory', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_swap_total', 'unique_id': '1234567890abcdef#system_swap_total', @@ -3984,6 +4065,7 @@ 'original_name': 'Updates available', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_system_apps_num_updates_available', 'unique_id': '1234567890abcdef#system_apps_num_updates_available', @@ -4032,6 +4114,7 @@ 'original_name': 'Webserver', 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nextcloud_server_webserver', 'unique_id': '1234567890abcdef#server_webserver', diff --git a/tests/components/nextcloud/snapshots/test_update.ambr b/tests/components/nextcloud/snapshots/test_update.ambr index a8acd2f5294..0a3ae568a44 100644 --- a/tests/components/nextcloud/snapshots/test_update.ambr +++ b/tests/components/nextcloud/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'nextcloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234567890abcdef#update', diff --git a/tests/components/nextdns/snapshots/test_binary_sensor.ambr b/tests/components/nextdns/snapshots/test_binary_sensor.ambr index 65a477f50f3..f8a05ad00ad 100644 --- a/tests/components/nextdns/snapshots/test_binary_sensor.ambr +++ b/tests/components/nextdns/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Device connection status', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_connection_status', 'unique_id': 'xyz12_this_device_nextdns_connection_status', @@ -75,6 +76,7 @@ 'original_name': 'Device profile connection status', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_profile_connection_status', 'unique_id': 'xyz12_this_device_profile_connection_status', diff --git a/tests/components/nextdns/snapshots/test_button.ambr b/tests/components/nextdns/snapshots/test_button.ambr index 3f1f75d1783..d416f9ef47e 100644 --- a/tests/components/nextdns/snapshots/test_button.ambr +++ b/tests/components/nextdns/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Clear logs', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'clear_logs', 'unique_id': 'xyz12_clear_logs', diff --git a/tests/components/nextdns/snapshots/test_sensor.ambr b/tests/components/nextdns/snapshots/test_sensor.ambr index 48c3b0894db..6aa061d1a9a 100644 --- a/tests/components/nextdns/snapshots/test_sensor.ambr +++ b/tests/components/nextdns/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'DNS-over-HTTP/3 queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'doh3_queries', 'unique_id': 'xyz12_doh3_queries', @@ -80,6 +81,7 @@ 'original_name': 'DNS-over-HTTP/3 queries ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'doh3_queries_ratio', 'unique_id': 'xyz12_doh3_queries_ratio', @@ -131,6 +133,7 @@ 'original_name': 'DNS-over-HTTPS queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'doh_queries', 'unique_id': 'xyz12_doh_queries', @@ -182,6 +185,7 @@ 'original_name': 'DNS-over-HTTPS queries ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'doh_queries_ratio', 'unique_id': 'xyz12_doh_queries_ratio', @@ -233,6 +237,7 @@ 'original_name': 'DNS-over-QUIC queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'doq_queries', 'unique_id': 'xyz12_doq_queries', @@ -284,6 +289,7 @@ 'original_name': 'DNS-over-QUIC queries ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'doq_queries_ratio', 'unique_id': 'xyz12_doq_queries_ratio', @@ -335,6 +341,7 @@ 'original_name': 'DNS-over-TLS queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dot_queries', 'unique_id': 'xyz12_dot_queries', @@ -386,6 +393,7 @@ 'original_name': 'DNS-over-TLS queries ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dot_queries_ratio', 'unique_id': 'xyz12_dot_queries_ratio', @@ -437,6 +445,7 @@ 'original_name': 'DNS queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'all_queries', 'unique_id': 'xyz12_all_queries', @@ -488,6 +497,7 @@ 'original_name': 'DNS queries blocked', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'blocked_queries', 'unique_id': 'xyz12_blocked_queries', @@ -539,6 +549,7 @@ 'original_name': 'DNS queries blocked ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'blocked_queries_ratio', 'unique_id': 'xyz12_blocked_queries_ratio', @@ -590,6 +601,7 @@ 'original_name': 'DNS queries relayed', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relayed_queries', 'unique_id': 'xyz12_relayed_queries', @@ -641,6 +653,7 @@ 'original_name': 'DNSSEC not validated queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'not_validated_queries', 'unique_id': 'xyz12_not_validated_queries', @@ -692,6 +705,7 @@ 'original_name': 'DNSSEC validated queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'validated_queries', 'unique_id': 'xyz12_validated_queries', @@ -743,6 +757,7 @@ 'original_name': 'DNSSEC validated queries ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'validated_queries_ratio', 'unique_id': 'xyz12_validated_queries_ratio', @@ -794,6 +809,7 @@ 'original_name': 'Encrypted queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'encrypted_queries', 'unique_id': 'xyz12_encrypted_queries', @@ -845,6 +861,7 @@ 'original_name': 'Encrypted queries ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'encrypted_queries_ratio', 'unique_id': 'xyz12_encrypted_queries_ratio', @@ -896,6 +913,7 @@ 'original_name': 'IPv4 queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ipv4_queries', 'unique_id': 'xyz12_ipv4_queries', @@ -947,6 +965,7 @@ 'original_name': 'IPv6 queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ipv6_queries', 'unique_id': 'xyz12_ipv6_queries', @@ -998,6 +1017,7 @@ 'original_name': 'IPv6 queries ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ipv6_queries_ratio', 'unique_id': 'xyz12_ipv6_queries_ratio', @@ -1049,6 +1069,7 @@ 'original_name': 'TCP queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tcp_queries', 'unique_id': 'xyz12_tcp_queries', @@ -1100,6 +1121,7 @@ 'original_name': 'TCP queries ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tcp_queries_ratio', 'unique_id': 'xyz12_tcp_queries_ratio', @@ -1151,6 +1173,7 @@ 'original_name': 'UDP queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'udp_queries', 'unique_id': 'xyz12_udp_queries', @@ -1202,6 +1225,7 @@ 'original_name': 'UDP queries ratio', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'udp_queries_ratio', 'unique_id': 'xyz12_udp_queries_ratio', @@ -1253,6 +1277,7 @@ 'original_name': 'Unencrypted queries', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'unencrypted_queries', 'unique_id': 'xyz12_unencrypted_queries', diff --git a/tests/components/nextdns/snapshots/test_switch.ambr b/tests/components/nextdns/snapshots/test_switch.ambr index e6d63b7f542..0b25baecd20 100644 --- a/tests/components/nextdns/snapshots/test_switch.ambr +++ b/tests/components/nextdns/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'AI-Driven threat detection', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ai_threat_detection', 'unique_id': 'xyz12_ai_threat_detection', @@ -74,6 +75,7 @@ 'original_name': 'Allow affiliate & tracking links', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'allow_affiliate', 'unique_id': 'xyz12_allow_affiliate', @@ -121,6 +123,7 @@ 'original_name': 'Anonymized EDNS client subnet', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'anonymized_ecs', 'unique_id': 'xyz12_anonymized_ecs', @@ -168,6 +171,7 @@ 'original_name': 'Block 9GAG', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_9gag', 'unique_id': 'xyz12_block_9gag', @@ -215,6 +219,7 @@ 'original_name': 'Block Amazon', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_amazon', 'unique_id': 'xyz12_block_amazon', @@ -262,6 +267,7 @@ 'original_name': 'Block BeReal', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_bereal', 'unique_id': 'xyz12_block_bereal', @@ -309,6 +315,7 @@ 'original_name': 'Block Blizzard', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_blizzard', 'unique_id': 'xyz12_block_blizzard', @@ -356,6 +363,7 @@ 'original_name': 'Block bypass methods', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_bypass_methods', 'unique_id': 'xyz12_block_bypass_methods', @@ -403,6 +411,7 @@ 'original_name': 'Block ChatGPT', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_chatgpt', 'unique_id': 'xyz12_block_chatgpt', @@ -450,6 +459,7 @@ 'original_name': 'Block child sexual abuse material', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_csam', 'unique_id': 'xyz12_block_csam', @@ -497,6 +507,7 @@ 'original_name': 'Block Dailymotion', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_dailymotion', 'unique_id': 'xyz12_block_dailymotion', @@ -544,6 +555,7 @@ 'original_name': 'Block dating', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_dating', 'unique_id': 'xyz12_block_dating', @@ -591,6 +603,7 @@ 'original_name': 'Block Discord', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_discord', 'unique_id': 'xyz12_block_discord', @@ -638,6 +651,7 @@ 'original_name': 'Block disguised third-party trackers', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_disguised_trackers', 'unique_id': 'xyz12_block_disguised_trackers', @@ -685,6 +699,7 @@ 'original_name': 'Block Disney Plus', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_disneyplus', 'unique_id': 'xyz12_block_disneyplus', @@ -732,6 +747,7 @@ 'original_name': 'Block dynamic DNS hostnames', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_ddns', 'unique_id': 'xyz12_block_ddns', @@ -779,6 +795,7 @@ 'original_name': 'Block eBay', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_ebay', 'unique_id': 'xyz12_block_ebay', @@ -826,6 +843,7 @@ 'original_name': 'Block Facebook', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_facebook', 'unique_id': 'xyz12_block_facebook', @@ -873,6 +891,7 @@ 'original_name': 'Block Fortnite', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_fortnite', 'unique_id': 'xyz12_block_fortnite', @@ -920,6 +939,7 @@ 'original_name': 'Block gambling', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_gambling', 'unique_id': 'xyz12_block_gambling', @@ -967,6 +987,7 @@ 'original_name': 'Block Google Chat', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_google_chat', 'unique_id': 'xyz12_block_google_chat', @@ -1014,6 +1035,7 @@ 'original_name': 'Block HBO Max', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_hbomax', 'unique_id': 'xyz12_block_hbomax', @@ -1061,6 +1083,7 @@ 'original_name': 'Block Hulu', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'xyz12_block_hulu', @@ -1108,6 +1131,7 @@ 'original_name': 'Block Imgur', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_imgur', 'unique_id': 'xyz12_block_imgur', @@ -1155,6 +1179,7 @@ 'original_name': 'Block Instagram', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_instagram', 'unique_id': 'xyz12_block_instagram', @@ -1202,6 +1227,7 @@ 'original_name': 'Block League of Legends', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_leagueoflegends', 'unique_id': 'xyz12_block_leagueoflegends', @@ -1249,6 +1275,7 @@ 'original_name': 'Block Mastodon', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_mastodon', 'unique_id': 'xyz12_block_mastodon', @@ -1296,6 +1323,7 @@ 'original_name': 'Block Messenger', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_messenger', 'unique_id': 'xyz12_block_messenger', @@ -1343,6 +1371,7 @@ 'original_name': 'Block Minecraft', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_minecraft', 'unique_id': 'xyz12_block_minecraft', @@ -1390,6 +1419,7 @@ 'original_name': 'Block Netflix', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_netflix', 'unique_id': 'xyz12_block_netflix', @@ -1437,6 +1467,7 @@ 'original_name': 'Block newly registered domains', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_nrd', 'unique_id': 'xyz12_block_nrd', @@ -1484,6 +1515,7 @@ 'original_name': 'Block online gaming', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_online_gaming', 'unique_id': 'xyz12_block_online_gaming', @@ -1531,6 +1563,7 @@ 'original_name': 'Block page', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_page', 'unique_id': 'xyz12_block_page', @@ -1578,6 +1611,7 @@ 'original_name': 'Block parked domains', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_parked_domains', 'unique_id': 'xyz12_block_parked_domains', @@ -1625,6 +1659,7 @@ 'original_name': 'Block Pinterest', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_pinterest', 'unique_id': 'xyz12_block_pinterest', @@ -1672,6 +1707,7 @@ 'original_name': 'Block piracy', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_piracy', 'unique_id': 'xyz12_block_piracy', @@ -1719,6 +1755,7 @@ 'original_name': 'Block PlayStation Network', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_playstation_network', 'unique_id': 'xyz12_block_playstation_network', @@ -1766,6 +1803,7 @@ 'original_name': 'Block porn', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_porn', 'unique_id': 'xyz12_block_porn', @@ -1813,6 +1851,7 @@ 'original_name': 'Block Prime Video', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_primevideo', 'unique_id': 'xyz12_block_primevideo', @@ -1860,6 +1899,7 @@ 'original_name': 'Block Reddit', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_reddit', 'unique_id': 'xyz12_block_reddit', @@ -1907,6 +1947,7 @@ 'original_name': 'Block Roblox', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_roblox', 'unique_id': 'xyz12_block_roblox', @@ -1954,6 +1995,7 @@ 'original_name': 'Block Signal', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_signal', 'unique_id': 'xyz12_block_signal', @@ -2001,6 +2043,7 @@ 'original_name': 'Block Skype', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_skype', 'unique_id': 'xyz12_block_skype', @@ -2048,6 +2091,7 @@ 'original_name': 'Block Snapchat', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_snapchat', 'unique_id': 'xyz12_block_snapchat', @@ -2095,6 +2139,7 @@ 'original_name': 'Block social networks', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_social_networks', 'unique_id': 'xyz12_block_social_networks', @@ -2142,6 +2187,7 @@ 'original_name': 'Block Spotify', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_spotify', 'unique_id': 'xyz12_block_spotify', @@ -2189,6 +2235,7 @@ 'original_name': 'Block Steam', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_steam', 'unique_id': 'xyz12_block_steam', @@ -2236,6 +2283,7 @@ 'original_name': 'Block Telegram', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_telegram', 'unique_id': 'xyz12_block_telegram', @@ -2283,6 +2331,7 @@ 'original_name': 'Block TikTok', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_tiktok', 'unique_id': 'xyz12_block_tiktok', @@ -2330,6 +2379,7 @@ 'original_name': 'Block Tinder', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_tinder', 'unique_id': 'xyz12_block_tinder', @@ -2377,6 +2427,7 @@ 'original_name': 'Block Tumblr', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_tumblr', 'unique_id': 'xyz12_block_tumblr', @@ -2424,6 +2475,7 @@ 'original_name': 'Block Twitch', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_twitch', 'unique_id': 'xyz12_block_twitch', @@ -2471,6 +2523,7 @@ 'original_name': 'Block video streaming', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_video_streaming', 'unique_id': 'xyz12_block_video_streaming', @@ -2518,6 +2571,7 @@ 'original_name': 'Block Vimeo', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_vimeo', 'unique_id': 'xyz12_block_vimeo', @@ -2565,6 +2619,7 @@ 'original_name': 'Block VK', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_vk', 'unique_id': 'xyz12_block_vk', @@ -2612,6 +2667,7 @@ 'original_name': 'Block WhatsApp', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_whatsapp', 'unique_id': 'xyz12_block_whatsapp', @@ -2659,6 +2715,7 @@ 'original_name': 'Block X (formerly Twitter)', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_twitter', 'unique_id': 'xyz12_block_twitter', @@ -2706,6 +2763,7 @@ 'original_name': 'Block Xbox Live', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_xboxlive', 'unique_id': 'xyz12_block_xboxlive', @@ -2753,6 +2811,7 @@ 'original_name': 'Block YouTube', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_youtube', 'unique_id': 'xyz12_block_youtube', @@ -2800,6 +2859,7 @@ 'original_name': 'Block Zoom', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_zoom', 'unique_id': 'xyz12_block_zoom', @@ -2847,6 +2907,7 @@ 'original_name': 'Cache boost', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cache_boost', 'unique_id': 'xyz12_cache_boost', @@ -2894,6 +2955,7 @@ 'original_name': 'CNAME flattening', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cname_flattening', 'unique_id': 'xyz12_cname_flattening', @@ -2941,6 +3003,7 @@ 'original_name': 'Cryptojacking protection', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cryptojacking_protection', 'unique_id': 'xyz12_cryptojacking_protection', @@ -2988,6 +3051,7 @@ 'original_name': 'DNS rebinding protection', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dns_rebinding_protection', 'unique_id': 'xyz12_dns_rebinding_protection', @@ -3035,6 +3099,7 @@ 'original_name': 'Domain generation algorithms protection', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dga_protection', 'unique_id': 'xyz12_dga_protection', @@ -3082,6 +3147,7 @@ 'original_name': 'Force SafeSearch', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'safesearch', 'unique_id': 'xyz12_safesearch', @@ -3129,6 +3195,7 @@ 'original_name': 'Force YouTube restricted mode', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'youtube_restricted_mode', 'unique_id': 'xyz12_youtube_restricted_mode', @@ -3176,6 +3243,7 @@ 'original_name': 'Google safe browsing', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'google_safe_browsing', 'unique_id': 'xyz12_google_safe_browsing', @@ -3223,6 +3291,7 @@ 'original_name': 'IDN homograph attacks protection', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'idn_homograph_attacks_protection', 'unique_id': 'xyz12_idn_homograph_attacks_protection', @@ -3270,6 +3339,7 @@ 'original_name': 'Logs', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'logs', 'unique_id': 'xyz12_logs', @@ -3317,6 +3387,7 @@ 'original_name': 'Threat intelligence feeds', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'threat_intelligence_feeds', 'unique_id': 'xyz12_threat_intelligence_feeds', @@ -3364,6 +3435,7 @@ 'original_name': 'Typosquatting protection', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'typosquatting_protection', 'unique_id': 'xyz12_typosquatting_protection', @@ -3411,6 +3483,7 @@ 'original_name': 'Web3', 'platform': 'nextdns', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'web3', 'unique_id': 'xyz12_web3', diff --git a/tests/components/nextdns/test_binary_sensor.py b/tests/components/nextdns/test_binary_sensor.py index 19cad755fb4..99e40af0dce 100644 --- a/tests/components/nextdns/test_binary_sensor.py +++ b/tests/components/nextdns/test_binary_sensor.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import patch from nextdns import ApiError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nextdns/test_button.py b/tests/components/nextdns/test_button.py index 3d2422c34a7..0cb4a7cd0df 100644 --- a/tests/components/nextdns/test_button.py +++ b/tests/components/nextdns/test_button.py @@ -6,7 +6,7 @@ from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError from nextdns import ApiError, InvalidApiKeyError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.nextdns.const import DOMAIN diff --git a/tests/components/nextdns/test_diagnostics.py b/tests/components/nextdns/test_diagnostics.py index 3bb1fc3ee67..4a5e09908ec 100644 --- a/tests/components/nextdns/test_diagnostics.py +++ b/tests/components/nextdns/test_diagnostics.py @@ -1,6 +1,6 @@ """Test NextDNS diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/nextdns/test_sensor.py b/tests/components/nextdns/test_sensor.py index eddf5a1cc5a..43e823fbf38 100644 --- a/tests/components/nextdns/test_sensor.py +++ b/tests/components/nextdns/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from nextdns import ApiError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index c85525ac457..1b0edb2c83c 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -7,7 +7,7 @@ from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError from nextdns import ApiError, InvalidApiKeyError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tenacity import RetryError from homeassistant.components.nextdns.const import DOMAIN diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index 073e142f7ff..91245503eb3 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -12,7 +12,7 @@ from nibe.coil_groups import ( ) from nibe.heatpump import Model import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_HVAC_MODE, diff --git a/tests/components/nibe_heatpump/test_coordinator.py b/tests/components/nibe_heatpump/test_coordinator.py index 2fade8e34d7..05c771ee420 100644 --- a/tests/components/nibe_heatpump/test_coordinator.py +++ b/tests/components/nibe_heatpump/test_coordinator.py @@ -7,7 +7,7 @@ from unittest.mock import patch from nibe.coil import Coil, CoilData from nibe.heatpump import Model import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index 73fed9ee08a..dc7faf0a80e 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from nibe.coil import CoilData from nibe.heatpump import Model import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/nice_go/snapshots/test_cover.ambr b/tests/components/nice_go/snapshots/test_cover.ambr index 0e1f9013a94..31ae154422d 100644 --- a/tests/components/nice_go/snapshots/test_cover.ambr +++ b/tests/components/nice_go/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'nice_go', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1', @@ -76,6 +77,7 @@ 'original_name': None, 'platform': 'nice_go', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '2', @@ -125,6 +127,7 @@ 'original_name': None, 'platform': 'nice_go', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '3', @@ -174,6 +177,7 @@ 'original_name': None, 'platform': 'nice_go', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '4', diff --git a/tests/components/nice_go/snapshots/test_light.ambr b/tests/components/nice_go/snapshots/test_light.ambr index 2b88b7d8d74..ffb5b8bff8d 100644 --- a/tests/components/nice_go/snapshots/test_light.ambr +++ b/tests/components/nice_go/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': 'Light', 'platform': 'nice_go', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': '1', @@ -87,6 +88,7 @@ 'original_name': 'Light', 'platform': 'nice_go', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': '2', diff --git a/tests/components/nice_go/test_cover.py b/tests/components/nice_go/test_cover.py index 542b1717d88..df708f64b8f 100644 --- a/tests/components/nice_go/test_cover.py +++ b/tests/components/nice_go/test_cover.py @@ -6,7 +6,7 @@ from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from nice_go import ApiError, AuthFailedError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, diff --git a/tests/components/nice_go/test_diagnostics.py b/tests/components/nice_go/test_diagnostics.py index 5c8647f3d6e..283709aa167 100644 --- a/tests/components/nice_go/test_diagnostics.py +++ b/tests/components/nice_go/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/nice_go/test_light.py b/tests/components/nice_go/test_light.py index 2bc9de59b2b..5c43367f169 100644 --- a/tests/components/nice_go/test_light.py +++ b/tests/components/nice_go/test_light.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from nice_go import ApiError, AuthFailedError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py index 130baf72228..35260b387de 100644 --- a/tests/components/niko_home_control/conftest.py +++ b/tests/components/niko_home_control/conftest.py @@ -45,7 +45,7 @@ def dimmable_light() -> NHCLight: mock.is_dimmable = True mock.name = "dimmable light" mock.suggested_area = "room" - mock.state = 100 + mock.state = 255 return mock diff --git a/tests/components/niko_home_control/snapshots/test_cover.ambr b/tests/components/niko_home_control/snapshots/test_cover.ambr index 5fe89497298..dc7cb0f4bce 100644 --- a/tests/components/niko_home_control/snapshots/test_cover.ambr +++ b/tests/components/niko_home_control/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'niko_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '01JFN93M7KRA38V5AMPCJ2JYYV-3', diff --git a/tests/components/niko_home_control/snapshots/test_light.ambr b/tests/components/niko_home_control/snapshots/test_light.ambr index adb0e743786..8cf1c0e97d7 100644 --- a/tests/components/niko_home_control/snapshots/test_light.ambr +++ b/tests/components/niko_home_control/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': None, 'platform': 'niko_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01JFN93M7KRA38V5AMPCJ2JYYV-2', @@ -88,6 +89,7 @@ 'original_name': None, 'platform': 'niko_home_control', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '01JFN93M7KRA38V5AMPCJ2JYYV-1', diff --git a/tests/components/niko_home_control/test_cover.py b/tests/components/niko_home_control/test_cover.py index 5e9a17c3324..3941c60b5c8 100644 --- a/tests/components/niko_home_control/test_cover.py +++ b/tests/components/niko_home_control/test_cover.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.const import ( diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py index 865e1303cb0..476ea95cda8 100644 --- a/tests/components/niko_home_control/test_light.py +++ b/tests/components/niko_home_control/test_light.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN from homeassistant.const import ( @@ -42,11 +42,11 @@ async def test_entities( @pytest.mark.parametrize( ("light_id", "data", "set_brightness"), [ - (0, {ATTR_ENTITY_ID: "light.light"}, 100), + (0, {ATTR_ENTITY_ID: "light.light"}, 255), ( 1, {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, - 20, + 50, ), ], ) @@ -121,8 +121,8 @@ async def test_updating( assert hass.states.get("light.dimmable_light").state == STATE_ON assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] == 255 - dimmable_light.state = 80 - await find_update_callback(mock_niko_home_control_connection, 2)(80) + dimmable_light.state = 204 + await find_update_callback(mock_niko_home_control_connection, 2)(204) await hass.async_block_till_done() assert hass.states.get("light.dimmable_light").state == STATE_ON diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index be2b04cc520..232836d1cc9 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Currency', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'currency', 'unique_id': 'SE3-currency', @@ -79,6 +80,7 @@ 'original_name': 'Current price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_price', 'unique_id': 'SE3-current_price', @@ -133,6 +135,7 @@ 'original_name': 'Daily average', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_average', 'unique_id': 'SE3-daily_average', @@ -184,6 +187,7 @@ 'original_name': 'Exchange rate', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'exchange_rate', 'unique_id': 'SE3-exchange_rate', @@ -235,6 +239,7 @@ 'original_name': 'Highest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'highest_price', 'unique_id': 'SE3-highest_price', @@ -285,6 +290,7 @@ 'original_name': 'Last updated', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'updated_at', 'unique_id': 'SE3-updated_at', @@ -336,6 +342,7 @@ 'original_name': 'Lowest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lowest_price', 'unique_id': 'SE3-lowest_price', @@ -389,6 +396,7 @@ 'original_name': 'Next price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'next_price', 'unique_id': 'SE3-next_price', @@ -442,6 +450,7 @@ 'original_name': 'Off-peak 1 average', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_average', 'unique_id': 'off_peak_1-SE3-block_average', @@ -496,6 +505,7 @@ 'original_name': 'Off-peak 1 highest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_max', 'unique_id': 'off_peak_1-SE3-block_max', @@ -550,6 +560,7 @@ 'original_name': 'Off-peak 1 lowest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_min', 'unique_id': 'off_peak_1-SE3-block_min', @@ -599,6 +610,7 @@ 'original_name': 'Off-peak 1 time from', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_start_time', 'unique_id': 'off_peak_1-SE3-block_start_time', @@ -647,6 +659,7 @@ 'original_name': 'Off-peak 1 time until', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_end_time', 'unique_id': 'off_peak_1-SE3-block_end_time', @@ -700,6 +713,7 @@ 'original_name': 'Off-peak 2 average', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_average', 'unique_id': 'off_peak_2-SE3-block_average', @@ -754,6 +768,7 @@ 'original_name': 'Off-peak 2 highest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_max', 'unique_id': 'off_peak_2-SE3-block_max', @@ -808,6 +823,7 @@ 'original_name': 'Off-peak 2 lowest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_min', 'unique_id': 'off_peak_2-SE3-block_min', @@ -857,6 +873,7 @@ 'original_name': 'Off-peak 2 time from', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_start_time', 'unique_id': 'off_peak_2-SE3-block_start_time', @@ -905,6 +922,7 @@ 'original_name': 'Off-peak 2 time until', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_end_time', 'unique_id': 'off_peak_2-SE3-block_end_time', @@ -958,6 +976,7 @@ 'original_name': 'Peak average', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_average', 'unique_id': 'peak-SE3-block_average', @@ -1012,6 +1031,7 @@ 'original_name': 'Peak highest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_max', 'unique_id': 'peak-SE3-block_max', @@ -1066,6 +1086,7 @@ 'original_name': 'Peak lowest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_min', 'unique_id': 'peak-SE3-block_min', @@ -1115,6 +1136,7 @@ 'original_name': 'Peak time from', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_start_time', 'unique_id': 'peak-SE3-block_start_time', @@ -1163,6 +1185,7 @@ 'original_name': 'Peak time until', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_end_time', 'unique_id': 'peak-SE3-block_end_time', @@ -1214,6 +1237,7 @@ 'original_name': 'Previous price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_price', 'unique_id': 'SE3-last_price', @@ -1262,6 +1286,7 @@ 'original_name': 'Currency', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'currency', 'unique_id': 'SE4-currency', @@ -1314,6 +1339,7 @@ 'original_name': 'Current price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_price', 'unique_id': 'SE4-current_price', @@ -1368,6 +1394,7 @@ 'original_name': 'Daily average', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_average', 'unique_id': 'SE4-daily_average', @@ -1419,6 +1446,7 @@ 'original_name': 'Exchange rate', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'exchange_rate', 'unique_id': 'SE4-exchange_rate', @@ -1470,6 +1498,7 @@ 'original_name': 'Highest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'highest_price', 'unique_id': 'SE4-highest_price', @@ -1520,6 +1549,7 @@ 'original_name': 'Last updated', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'updated_at', 'unique_id': 'SE4-updated_at', @@ -1571,6 +1601,7 @@ 'original_name': 'Lowest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lowest_price', 'unique_id': 'SE4-lowest_price', @@ -1624,6 +1655,7 @@ 'original_name': 'Next price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'next_price', 'unique_id': 'SE4-next_price', @@ -1677,6 +1709,7 @@ 'original_name': 'Off-peak 1 average', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_average', 'unique_id': 'off_peak_1-SE4-block_average', @@ -1731,6 +1764,7 @@ 'original_name': 'Off-peak 1 highest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_max', 'unique_id': 'off_peak_1-SE4-block_max', @@ -1785,6 +1819,7 @@ 'original_name': 'Off-peak 1 lowest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_min', 'unique_id': 'off_peak_1-SE4-block_min', @@ -1834,6 +1869,7 @@ 'original_name': 'Off-peak 1 time from', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_start_time', 'unique_id': 'off_peak_1-SE4-block_start_time', @@ -1882,6 +1918,7 @@ 'original_name': 'Off-peak 1 time until', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_end_time', 'unique_id': 'off_peak_1-SE4-block_end_time', @@ -1935,6 +1972,7 @@ 'original_name': 'Off-peak 2 average', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_average', 'unique_id': 'off_peak_2-SE4-block_average', @@ -1989,6 +2027,7 @@ 'original_name': 'Off-peak 2 highest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_max', 'unique_id': 'off_peak_2-SE4-block_max', @@ -2043,6 +2082,7 @@ 'original_name': 'Off-peak 2 lowest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_min', 'unique_id': 'off_peak_2-SE4-block_min', @@ -2092,6 +2132,7 @@ 'original_name': 'Off-peak 2 time from', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_start_time', 'unique_id': 'off_peak_2-SE4-block_start_time', @@ -2140,6 +2181,7 @@ 'original_name': 'Off-peak 2 time until', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_end_time', 'unique_id': 'off_peak_2-SE4-block_end_time', @@ -2193,6 +2235,7 @@ 'original_name': 'Peak average', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_average', 'unique_id': 'peak-SE4-block_average', @@ -2247,6 +2290,7 @@ 'original_name': 'Peak highest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_max', 'unique_id': 'peak-SE4-block_max', @@ -2301,6 +2345,7 @@ 'original_name': 'Peak lowest price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_min', 'unique_id': 'peak-SE4-block_min', @@ -2350,6 +2395,7 @@ 'original_name': 'Peak time from', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_start_time', 'unique_id': 'peak-SE4-block_start_time', @@ -2398,6 +2444,7 @@ 'original_name': 'Peak time until', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_end_time', 'unique_id': 'peak-SE4-block_end_time', @@ -2449,6 +2496,7 @@ 'original_name': 'Previous price', 'platform': 'nordpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_price', 'unique_id': 'SE4-last_price', diff --git a/tests/components/nordpool/snapshots/test_services.ambr b/tests/components/nordpool/snapshots/test_services.ambr index 6a57d7ecce9..b271b433061 100644 --- a/tests/components/nordpool/snapshots/test_services.ambr +++ b/tests/components/nordpool/snapshots/test_services.ambr @@ -1,4 +1,10 @@ # serializer version: 1 +# name: test_empty_response_returns_empty_list + dict({ + 'SE3': list([ + ]), + }) +# --- # name: test_service_call dict({ 'SE3': list([ diff --git a/tests/components/nordpool/test_services.py b/tests/components/nordpool/test_services.py index 6d6af685d28..d59ec4712d7 100644 --- a/tests/components/nordpool/test_services.py +++ b/tests/components/nordpool/test_services.py @@ -74,7 +74,6 @@ async def test_service_call( ("error", "key"), [ (NordPoolAuthenticationError, "authentication_error"), - (NordPoolEmptyResponseError, "empty_response"), (NordPoolError, "connection_error"), ], ) @@ -106,6 +105,33 @@ async def test_service_call_failures( assert err.value.translation_key == key +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_empty_response_returns_empty_list( + hass: HomeAssistant, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test get_prices_for_date service call return empty list for empty response.""" + service_data = TEST_SERVICE_DATA.copy() + service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=NordPoolEmptyResponseError, + ), + ): + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + service_data, + blocking=True, + return_response=True, + ) + + assert response == snapshot + + @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") async def test_service_call_config_entry_bad_state( hass: HomeAssistant, diff --git a/tests/components/ntfy/snapshots/test_notify.ambr b/tests/components/ntfy/snapshots/test_notify.ambr index 619ae59cc2f..34320ed5655 100644 --- a/tests/components/ntfy/snapshots/test_notify.ambr +++ b/tests/components/ntfy/snapshots/test_notify.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'ntfy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'publish', 'unique_id': '123456789_ABCDEF_publish', diff --git a/tests/components/ntfy/test_notify.py b/tests/components/ntfy/test_notify.py index 76bf1049ae8..ec947ba5a1f 100644 --- a/tests/components/ntfy/test_notify.py +++ b/tests/components/ntfy/test_notify.py @@ -4,7 +4,11 @@ from collections.abc import AsyncGenerator from unittest.mock import patch from aiontfy import Message -from aiontfy.exceptions import NtfyException, NtfyHTTPError +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) from freezegun.api import freeze_time import pytest from syrupy.assertion import SnapshotAssertion @@ -15,7 +19,8 @@ from homeassistant.components.notify import ( DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.ntfy.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -101,6 +106,10 @@ async def test_send_message( NtfyException, "Failed to publish notification due to a connection error", ), + ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + "Failed to authenticate with ntfy service. Please verify your credentials", + ), ], ) async def test_send_message_exception( @@ -135,3 +144,44 @@ async def test_send_message_exception( mock_aiontfy.publish.assert_called_once_with( Message(topic="mytopic", message="triggered", title="test") ) + + +async def test_send_message_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test unauthorized exception initiates reauth flow.""" + + 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 = ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + 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") == config_entry.entry_id diff --git a/tests/components/nuki/snapshots/test_binary_sensor.ambr b/tests/components/nuki/snapshots/test_binary_sensor.ambr index e48cc55bfb3..88e803115bc 100644 --- a/tests/components/nuki/snapshots/test_binary_sensor.ambr +++ b/tests/components/nuki/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'nuki', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2_battery_critical', @@ -75,6 +76,7 @@ 'original_name': 'Ring Action', 'platform': 'nuki', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ring_action', 'unique_id': '2_ringaction', @@ -122,6 +124,7 @@ 'original_name': None, 'platform': 'nuki', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1_doorsensor', @@ -170,6 +173,7 @@ 'original_name': 'Battery', 'platform': 'nuki', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1_battery_critical', @@ -218,6 +222,7 @@ 'original_name': 'Charging', 'platform': 'nuki', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1_battery_charging', diff --git a/tests/components/nuki/snapshots/test_lock.ambr b/tests/components/nuki/snapshots/test_lock.ambr index 2d80110a5cc..07a0f048fe1 100644 --- a/tests/components/nuki/snapshots/test_lock.ambr +++ b/tests/components/nuki/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'nuki', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'nuki_lock', 'unique_id': 2, @@ -75,6 +76,7 @@ 'original_name': None, 'platform': 'nuki', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'nuki_lock', 'unique_id': 1, diff --git a/tests/components/nuki/snapshots/test_sensor.ambr b/tests/components/nuki/snapshots/test_sensor.ambr index 5be025727be..55f2d1aac3c 100644 --- a/tests/components/nuki/snapshots/test_sensor.ambr +++ b/tests/components/nuki/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'nuki', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1_battery_level', diff --git a/tests/components/nuki/test_binary_sensor.py b/tests/components/nuki/test_binary_sensor.py index 54fbc93c144..11507100aae 100644 --- a/tests/components/nuki/test_binary_sensor.py +++ b/tests/components/nuki/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nuki/test_lock.py b/tests/components/nuki/test_lock.py index 824d508f3dc..fc2d9d1cba8 100644 --- a/tests/components/nuki/test_lock.py +++ b/tests/components/nuki/test_lock.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nuki/test_sensor.py b/tests/components/nuki/test_sensor.py index dde803d573f..69a0aec56f7 100644 --- a/tests/components/nuki/test_sensor.py +++ b/tests/components/nuki/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index 1de8f67fbdb..fb00d67d9ff 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -86,28 +86,32 @@ SENSOR_EXPECTED_OBSERVATION_IMPERIAL = { round( TemperatureConverter.convert( 5, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT - ) + ), + 1, ) ), "temperature": str( round( TemperatureConverter.convert( 10, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT - ) + ), + 1, ) ), "windChill": str( round( TemperatureConverter.convert( 5, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT - ) + ), + 1, ) ), "heatIndex": str( round( TemperatureConverter.convert( 15, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT - ) + ), + 1, ) ), "relativeHumidity": "10", @@ -115,14 +119,14 @@ SENSOR_EXPECTED_OBSERVATION_IMPERIAL = { round( SpeedConverter.convert( 10, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR - ) + ), ) ), "windGust": str( round( SpeedConverter.convert( 20, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR - ) + ), ) ), "windDirection": "180", @@ -234,5 +238,4 @@ EXPECTED_FORECAST_METRIC = { ), ATTR_FORECAST_HUMIDITY: 75, } - NONE_FORECAST = [dict.fromkeys(DEFAULT_FORECAST[0])] diff --git a/tests/components/nws/test_diagnostics.py b/tests/components/nws/test_diagnostics.py index 55f7f3100a0..fecd74eb0f4 100644 --- a/tests/components/nws/test_diagnostics.py +++ b/tests/components/nws/test_diagnostics.py @@ -1,6 +1,6 @@ """Test NWS diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import nws from homeassistant.core import HomeAssistant diff --git a/tests/components/nws/test_sensor.py b/tests/components/nws/test_sensor.py index dd69d5ac775..acdccf4f6c7 100644 --- a/tests/components/nws/test_sensor.py +++ b/tests/components/nws/test_sensor.py @@ -66,7 +66,9 @@ async def test_imperial_metric( assert description.name state = hass.states.get(f"sensor.abc_{slugify(description.name)}") assert state - assert state.state == result_observation[description.key] + assert state.state == result_observation[description.key], ( + f"Failed for {description.key}" + ) assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index 8201c26739c..5a1aa384f0f 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current streak', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'streak', 'unique_id': '218886794-connections-connections_streak', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Highest streak', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_streak', 'unique_id': '218886794-connections-connections_max_streak', @@ -131,6 +139,7 @@ 'original_name': 'Last played', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_played', 'unique_id': '218886794-connections-connections_last_played', @@ -181,6 +190,7 @@ 'original_name': 'Played', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connections_played', 'unique_id': '218886794-connections-connections_played', @@ -232,6 +242,7 @@ 'original_name': 'Won', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'won', 'unique_id': '218886794-connections-connections_won', @@ -283,6 +294,7 @@ 'original_name': 'Played', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'spelling_bees_played', 'unique_id': '218886794-spelling_bee-spelling_bees_played', @@ -334,6 +346,7 @@ 'original_name': 'Total pangrams found', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_pangrams', 'unique_id': '218886794-spelling_bee-spelling_bees_total_pangrams', @@ -385,6 +398,7 @@ 'original_name': 'Total words found', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_words', 'unique_id': '218886794-spelling_bee-spelling_bees_total_words', @@ -430,12 +444,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current streak', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'streak', 'unique_id': '218886794-wordle-wordles_streak', @@ -482,12 +500,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Highest streak', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_streak', 'unique_id': '218886794-wordle-wordles_max_streak', @@ -540,6 +562,7 @@ 'original_name': 'Played', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wordles_played', 'unique_id': '218886794-wordle-wordles_played', @@ -591,6 +614,7 @@ 'original_name': 'Won', 'platform': 'nyt_games', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'won', 'unique_id': '218886794-wordle-wordles_won', diff --git a/tests/components/nyt_games/test_init.py b/tests/components/nyt_games/test_init.py index 2e1a8c92f90..ced155ac5a2 100644 --- a/tests/components/nyt_games/test_init.py +++ b/tests/components/nyt_games/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.nyt_games.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/nyt_games/test_sensor.py b/tests/components/nyt_games/test_sensor.py index f35caf20b57..5802b38dd83 100644 --- a/tests/components/nyt_games/test_sensor.py +++ b/tests/components/nyt_games/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from nyt_games import NYTGamesError, WordleStats import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.nyt_games.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE diff --git a/tests/components/nzbget/test_sensor.py b/tests/components/nzbget/test_sensor.py index 38f7d8a68c3..62ff0c1f59f 100644 --- a/tests/components/nzbget/test_sensor.py +++ b/tests/components/nzbget/test_sensor.py @@ -36,14 +36,14 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) ), "average_speed": ( "AverageDownloadRate", - "1.250000", + "1.25", UnitOfDataRate.MEGABYTES_PER_SECOND, SensorDeviceClass.DATA_RATE, ), "download_paused": ("DownloadPaused", "False", None, None), "speed": ( "DownloadRate", - "2.500000", + "2.5", UnitOfDataRate.MEGABYTES_PER_SECOND, SensorDeviceClass.DATA_RATE, ), @@ -70,7 +70,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) "uptime": ("UpTimeSec", uptime.isoformat(), None, SensorDeviceClass.TIMESTAMP), "speed_limit": ( "DownloadLimit", - "1.000000", + "1.0", UnitOfDataRate.MEGABYTES_PER_SECOND, SensorDeviceClass.DATA_RATE, ), diff --git a/tests/components/ohme/snapshots/test_button.ambr b/tests/components/ohme/snapshots/test_button.ambr index b276e8c3c42..88cf6327bcf 100644 --- a/tests/components/ohme/snapshots/test_button.ambr +++ b/tests/components/ohme/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Approve charge', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'approve', 'unique_id': 'chargerid_approve', diff --git a/tests/components/ohme/snapshots/test_number.ambr b/tests/components/ohme/snapshots/test_number.ambr index 69e18d0b2a7..80ee4d30d9c 100644 --- a/tests/components/ohme/snapshots/test_number.ambr +++ b/tests/components/ohme/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Preconditioning duration', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'preconditioning_duration', 'unique_id': 'chargerid_preconditioning_duration', @@ -89,6 +90,7 @@ 'original_name': 'Target percentage', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'target_percentage', 'unique_id': 'chargerid_target_percentage', diff --git a/tests/components/ohme/snapshots/test_select.ambr b/tests/components/ohme/snapshots/test_select.ambr index 063a9616588..1897e146c01 100644 --- a/tests/components/ohme/snapshots/test_select.ambr +++ b/tests/components/ohme/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Charge mode', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'chargerid_charge_mode', @@ -90,6 +91,7 @@ 'original_name': 'Vehicle', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle', 'unique_id': 'chargerid_vehicle', diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index 9cef4bfffd9..c22d43a451b 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge slots', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'slot_list', 'unique_id': 'chargerid_slot_list', @@ -68,12 +69,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'CT current', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ct_current', 'unique_id': 'chargerid_ct_current', @@ -117,12 +122,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'chargerid_current', @@ -180,6 +189,7 @@ 'original_name': 'Energy', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'chargerid_energy', @@ -236,6 +246,7 @@ 'original_name': 'Power', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'chargerid_power', @@ -294,6 +305,7 @@ 'original_name': 'Status', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', 'unique_id': 'chargerid_status', @@ -353,6 +365,7 @@ 'original_name': 'Vehicle battery', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_battery', 'unique_id': 'chargerid_battery', @@ -398,12 +411,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'chargerid_voltage', diff --git a/tests/components/ohme/snapshots/test_switch.ambr b/tests/components/ohme/snapshots/test_switch.ambr index 4790d96c551..ef91187f160 100644 --- a/tests/components/ohme/snapshots/test_switch.ambr +++ b/tests/components/ohme/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Lock buttons', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lock_buttons', 'unique_id': 'chargerid_lock_buttons', @@ -74,6 +75,7 @@ 'original_name': 'Price cap', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'price_cap', 'unique_id': 'chargerid_price_cap', @@ -121,6 +123,7 @@ 'original_name': 'Require approval', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'require_approval', 'unique_id': 'chargerid_require_approval', @@ -168,6 +171,7 @@ 'original_name': 'Sleep when inactive', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sleep_when_inactive', 'unique_id': 'chargerid_sleep_when_inactive', diff --git a/tests/components/ohme/snapshots/test_time.ambr b/tests/components/ohme/snapshots/test_time.ambr index 8c85fc2298e..1f77bb1f17a 100644 --- a/tests/components/ohme/snapshots/test_time.ambr +++ b/tests/components/ohme/snapshots/test_time.ambr @@ -27,6 +27,7 @@ 'original_name': 'Target time', 'platform': 'ohme', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'target_time', 'unique_id': 'chargerid_target_time', diff --git a/tests/components/ohme/test_button.py b/tests/components/ohme/test_button.py index 1728563b2e9..70dab600b6d 100644 --- a/tests/components/ohme/test_button.py +++ b/tests/components/ohme/test_button.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from ohme import ChargerStatus -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ( diff --git a/tests/components/ohme/test_diagnostics.py b/tests/components/ohme/test_diagnostics.py index 6aab1262189..25ee5ae10db 100644 --- a/tests/components/ohme/test_diagnostics.py +++ b/tests/components/ohme/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/ohme/test_init.py b/tests/components/ohme/test_init.py index 0f4c7cd64ee..7d9d388867f 100644 --- a/tests/components/ohme/test_init.py +++ b/tests/components/ohme/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ohme.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/ohme/test_number.py b/tests/components/ohme/test_number.py index 9cfce2a850f..e162cd337ae 100644 --- a/tests/components/ohme/test_number.py +++ b/tests/components/ohme/test_number.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/ohme/test_select.py b/tests/components/ohme/test_select.py index 5aeebc1f477..1f0225fd70f 100644 --- a/tests/components/ohme/test_select.py +++ b/tests/components/ohme/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from ohme import ChargerMode -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/ohme/test_sensor.py b/tests/components/ohme/test_sensor.py index 8fc9edddcf9..b7c8f82aafc 100644 --- a/tests/components/ohme/test_sensor.py +++ b/tests/components/ohme/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from ohme import ApiException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/ohme/test_switch.py b/tests/components/ohme/test_switch.py index 8d82a5a3ea4..976b5cfcccd 100644 --- a/tests/components/ohme/test_switch.py +++ b/tests/components/ohme/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/ohme/test_time.py b/tests/components/ohme/test_time.py index 0562dfa124c..8c604e19086 100644 --- a/tests/components/ohme/test_time.py +++ b/tests/components/ohme/test_time.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.time import ( ATTR_TIME, diff --git a/tests/components/omnilogic/snapshots/test_sensor.ambr b/tests/components/omnilogic/snapshots/test_sensor.ambr index b6eb07dbe26..f5de91b4199 100644 --- a/tests/components/omnilogic/snapshots/test_sensor.ambr +++ b/tests/components/omnilogic/snapshots/test_sensor.ambr @@ -21,12 +21,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'SCRUBBED Air Temperature', 'platform': 'omnilogic', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'SCRUBBED_SCRUBBED_air_temperature', @@ -47,7 +51,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '21', + 'state': '21.1111111111111', }) # --- # name: test_sensors[sensor.scrubbed_spa_water_temperature-entry] @@ -72,12 +76,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'SCRUBBED Spa Water Temperature', 'platform': 'omnilogic', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'SCRUBBED_1_water_temperature', @@ -98,6 +106,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '22', + 'state': '21.6666666666667', }) # --- diff --git a/tests/components/omnilogic/snapshots/test_switch.ambr b/tests/components/omnilogic/snapshots/test_switch.ambr index cc1a2e226fc..34cd555edf8 100644 --- a/tests/components/omnilogic/snapshots/test_switch.ambr +++ b/tests/components/omnilogic/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'SCRUBBED Spa Filter Pump ', 'platform': 'omnilogic', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'SCRUBBED_1_2_pump', @@ -74,6 +75,7 @@ 'original_name': 'SCRUBBED Spa Spa Jets ', 'platform': 'omnilogic', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'SCRUBBED_1_5_pump', diff --git a/tests/components/omnilogic/test_sensor.py b/tests/components/omnilogic/test_sensor.py index 166eb7f87f2..ed7d781ab2d 100644 --- a/tests/components/omnilogic/test_sensor.py +++ b/tests/components/omnilogic/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/omnilogic/test_switch.py b/tests/components/omnilogic/test_switch.py index 1f9506380a2..adc8fe04763 100644 --- a/tests/components/omnilogic/test_switch.py +++ b/tests/components/omnilogic/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/ondilo_ico/snapshots/test_sensor.ambr b/tests/components/ondilo_ico/snapshots/test_sensor.ambr index 7df2bfc22ce..81274bc3a76 100644 --- a/tests/components/ondilo_ico/snapshots/test_sensor.ambr +++ b/tests/components/ondilo_ico/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'W1122333044455-battery', @@ -81,6 +82,7 @@ 'original_name': 'Oxydo reduction potential', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oxydo_reduction_potential', 'unique_id': 'W1122333044455-orp', @@ -132,6 +134,7 @@ 'original_name': 'pH', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'W1122333044455-ph', @@ -183,6 +186,7 @@ 'original_name': 'RSSI', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rssi', 'unique_id': 'W1122333044455-rssi', @@ -234,6 +238,7 @@ 'original_name': 'Salt', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salt', 'unique_id': 'W1122333044455-salt', @@ -285,6 +290,7 @@ 'original_name': 'TDS', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tds', 'unique_id': 'W1122333044455-tds', @@ -330,12 +336,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'W1122333044455-temperature', @@ -388,6 +398,7 @@ 'original_name': 'Battery', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'W2233304445566-battery', @@ -440,6 +451,7 @@ 'original_name': 'Oxydo reduction potential', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oxydo_reduction_potential', 'unique_id': 'W2233304445566-orp', @@ -491,6 +503,7 @@ 'original_name': 'pH', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'W2233304445566-ph', @@ -542,6 +555,7 @@ 'original_name': 'RSSI', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rssi', 'unique_id': 'W2233304445566-rssi', @@ -593,6 +607,7 @@ 'original_name': 'Salt', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'salt', 'unique_id': 'W2233304445566-salt', @@ -644,6 +659,7 @@ 'original_name': 'TDS', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tds', 'unique_id': 'W2233304445566-tds', @@ -689,12 +705,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'ondilo_ico', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'W2233304445566-temperature', diff --git a/tests/components/ondilo_ico/test_init.py b/tests/components/ondilo_ico/test_init.py index 58b1e27987d..d93c5ce4df6 100644 --- a/tests/components/ondilo_ico/test_init.py +++ b/tests/components/ondilo_ico/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from ondilo import OndiloError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/ondilo_ico/test_sensor.py b/tests/components/ondilo_ico/test_sensor.py index c944353724e..8785ca39880 100644 --- a/tests/components/ondilo_ico/test_sensor.py +++ b/tests/components/ondilo_ico/test_sensor.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import MagicMock, patch from ondilo import OndiloError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/onedrive/snapshots/test_sensor.ambr b/tests/components/onedrive/snapshots/test_sensor.ambr index 742c069f206..53bcf39eeeb 100644 --- a/tests/components/onedrive/snapshots/test_sensor.ambr +++ b/tests/components/onedrive/snapshots/test_sensor.ambr @@ -34,6 +34,7 @@ 'original_name': 'Drive state', 'platform': 'onedrive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state', 'unique_id': 'mock_drive_id_drive_state', @@ -94,6 +95,7 @@ 'original_name': 'Remaining storage', 'platform': 'onedrive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_size', 'unique_id': 'mock_drive_id_remaining_size', @@ -149,6 +151,7 @@ 'original_name': 'Total available storage', 'platform': 'onedrive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_size', 'unique_id': 'mock_drive_id_total_size', @@ -204,6 +207,7 @@ 'original_name': 'Used storage', 'platform': 'onedrive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'used_size', 'unique_id': 'mock_drive_id_used_size', diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index f3f2fbdad40..4d0abd5a602 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -91,14 +91,16 @@ async def test_agents_list_backups( "onedrive.mock_drive_id": {"protected": False, "size": 34519040} }, "backup_id": "23e64aec", - "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "date": "2024-11-22T11:48:48.727189+01:00", "extra_metadata": {}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "failed_agent_ids": [], "with_automatic_settings": None, } ] @@ -143,14 +145,16 @@ async def test_agents_get_backup( } }, "backup_id": "23e64aec", - "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "date": "2024-11-22T11:48:48.727189+01:00", "extra_metadata": {}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "failed_agent_ids": [], "with_automatic_settings": None, } diff --git a/tests/components/onedrive/test_diagnostics.py b/tests/components/onedrive/test_diagnostics.py index f82d9925ee6..9be8455f287 100644 --- a/tests/components/onedrive/test_diagnostics.py +++ b/tests/components/onedrive/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the OneDrive integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 952ca01e1cb..af12f66b60e 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -13,7 +13,7 @@ from onedrive_personal_sdk.exceptions import ( ) from onedrive_personal_sdk.models.items import AppRoot, Drive, File, Folder, ItemUpdate import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.onedrive.const import ( CONF_FOLDER_ID, diff --git a/tests/components/onedrive/test_sensor.py b/tests/components/onedrive/test_sensor.py index ea9d93a9a7b..18e8ad85ac2 100644 --- a/tests/components/onedrive/test_sensor.py +++ b/tests/components/onedrive/test_sensor.py @@ -9,7 +9,7 @@ from onedrive_personal_sdk.const import DriveType from onedrive_personal_sdk.exceptions import HttpRequestException from onedrive_personal_sdk.models.items import Drive import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 10122ba8685..6309b80b28d 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Sensed A', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/12.111111111111/sensed.A', @@ -76,6 +77,7 @@ 'original_name': 'Sensed B', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/12.111111111111/sensed.B', @@ -125,6 +127,7 @@ 'original_name': 'Sensed 0', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/29.111111111111/sensed.0', @@ -174,6 +177,7 @@ 'original_name': 'Sensed 1', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/29.111111111111/sensed.1', @@ -223,6 +227,7 @@ 'original_name': 'Sensed 2', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/29.111111111111/sensed.2', @@ -272,6 +277,7 @@ 'original_name': 'Sensed 3', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/29.111111111111/sensed.3', @@ -321,6 +327,7 @@ 'original_name': 'Sensed 4', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/29.111111111111/sensed.4', @@ -370,6 +377,7 @@ 'original_name': 'Sensed 5', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/29.111111111111/sensed.5', @@ -419,6 +427,7 @@ 'original_name': 'Sensed 6', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/29.111111111111/sensed.6', @@ -468,6 +477,7 @@ 'original_name': 'Sensed 7', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/29.111111111111/sensed.7', @@ -517,6 +527,7 @@ 'original_name': 'Sensed A', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/3A.111111111111/sensed.A', @@ -566,6 +577,7 @@ 'original_name': 'Sensed B', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensed_id', 'unique_id': '/3A.111111111111/sensed.B', @@ -615,6 +627,7 @@ 'original_name': 'Hub short on branch 0', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hub_short_id', 'unique_id': '/EF.111111111113/hub/short.0', @@ -665,6 +678,7 @@ 'original_name': 'Hub short on branch 1', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hub_short_id', 'unique_id': '/EF.111111111113/hub/short.1', @@ -715,6 +729,7 @@ 'original_name': 'Hub short on branch 2', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hub_short_id', 'unique_id': '/EF.111111111113/hub/short.2', @@ -765,6 +780,7 @@ 'original_name': 'Hub short on branch 3', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hub_short_id', 'unique_id': '/EF.111111111113/hub/short.3', diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr index a896d946841..9861a7d2f5e 100644 --- a/tests/components/onewire/snapshots/test_select.ambr +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -34,6 +34,7 @@ 'original_name': 'Temperature resolution', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tempres', 'unique_id': '/28.111111111111/tempres', diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index eca459b4c57..8b49b7f3d5f 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/10.111111111111/temperature', @@ -77,12 +81,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/pressure', @@ -131,12 +139,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/temperature', @@ -191,6 +203,7 @@ 'original_name': 'Counter A', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'counter_id', 'unique_id': '/1D.111111111111/counter.A', @@ -243,6 +256,7 @@ 'original_name': 'Counter B', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'counter_id', 'unique_id': '/1D.111111111111/counter.B', @@ -289,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Latest voltage A', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latest_voltage_id', 'unique_id': '/20.111111111111/latestvolt.A', @@ -343,12 +361,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Latest voltage B', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latest_voltage_id', 'unique_id': '/20.111111111111/latestvolt.B', @@ -397,12 +419,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Latest voltage C', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latest_voltage_id', 'unique_id': '/20.111111111111/latestvolt.C', @@ -451,12 +477,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Latest voltage D', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latest_voltage_id', 'unique_id': '/20.111111111111/latestvolt.D', @@ -505,12 +535,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage A', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_id', 'unique_id': '/20.111111111111/volt.A', @@ -559,12 +593,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage B', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_id', 'unique_id': '/20.111111111111/volt.B', @@ -613,12 +651,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage C', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_id', 'unique_id': '/20.111111111111/volt.C', @@ -667,12 +709,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage D', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_id', 'unique_id': '/20.111111111111/volt.D', @@ -721,12 +767,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/22.111111111111/temperature', @@ -781,6 +831,7 @@ 'original_name': 'HIH3600 humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih3600', 'unique_id': '/26.111111111111/HIH3600/humidity', @@ -835,6 +886,7 @@ 'original_name': 'HIH4000 humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih4000', 'unique_id': '/26.111111111111/HIH4000/humidity', @@ -889,6 +941,7 @@ 'original_name': 'HIH5030 humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih5030', 'unique_id': '/26.111111111111/HIH5030/humidity', @@ -943,6 +996,7 @@ 'original_name': 'HTM1735 humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_htm1735', 'unique_id': '/26.111111111111/HTM1735/humidity', @@ -997,6 +1051,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/humidity', @@ -1051,6 +1106,7 @@ 'original_name': 'Illuminance', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/S3-R1-A/illuminance', @@ -1099,12 +1155,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/B1-R1-A/pressure', @@ -1153,12 +1213,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/temperature', @@ -1207,12 +1271,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VAD voltage', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_vad', 'unique_id': '/26.111111111111/VAD', @@ -1261,12 +1329,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VDD voltage', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_vdd', 'unique_id': '/26.111111111111/VDD', @@ -1315,12 +1387,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VIS voltage difference', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_vis', 'unique_id': '/26.111111111111/vis', @@ -1369,12 +1445,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/28.111111111111/temperature', @@ -1423,12 +1503,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/28.222222222222/temperature', @@ -1477,12 +1561,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/28.222222222223/temperature', @@ -1531,12 +1619,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/30.111111111111/temperature', @@ -1585,12 +1677,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Thermocouple K temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermocouple_temperature_k', 'unique_id': '/30.111111111111/typeX/temperature', @@ -1639,12 +1735,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VIS voltage gradient', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_vis_gradient', 'unique_id': '/30.111111111111/vis', @@ -1693,12 +1793,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/30.111111111111/volt', @@ -1747,12 +1851,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/3B.111111111111/temperature', @@ -1801,12 +1909,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/42.111111111111/temperature', @@ -1861,6 +1973,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/humidity', @@ -1915,6 +2028,7 @@ 'original_name': 'Illuminance', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/light', @@ -1963,12 +2077,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/pressure', @@ -2017,12 +2135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/temperature', @@ -2071,12 +2193,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/pressure', @@ -2125,12 +2251,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/temperature', @@ -2185,6 +2315,7 @@ 'original_name': 'HIH3600 humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih3600', 'unique_id': '/A6.111111111111/HIH3600/humidity', @@ -2239,6 +2370,7 @@ 'original_name': 'HIH4000 humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih4000', 'unique_id': '/A6.111111111111/HIH4000/humidity', @@ -2293,6 +2425,7 @@ 'original_name': 'HIH5030 humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih5030', 'unique_id': '/A6.111111111111/HIH5030/humidity', @@ -2347,6 +2480,7 @@ 'original_name': 'HTM1735 humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_htm1735', 'unique_id': '/A6.111111111111/HTM1735/humidity', @@ -2401,6 +2535,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/A6.111111111111/humidity', @@ -2455,6 +2590,7 @@ 'original_name': 'Illuminance', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/A6.111111111111/S3-R1-A/illuminance', @@ -2503,12 +2639,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/A6.111111111111/B1-R1-A/pressure', @@ -2557,12 +2697,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/A6.111111111111/temperature', @@ -2611,12 +2755,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VAD voltage', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_vad', 'unique_id': '/A6.111111111111/VAD', @@ -2665,12 +2813,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VDD voltage', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_vdd', 'unique_id': '/A6.111111111111/VDD', @@ -2719,12 +2871,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'VIS voltage difference', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_vis', 'unique_id': '/A6.111111111111/vis', @@ -2779,6 +2935,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/humidity_corrected', @@ -2833,6 +2990,7 @@ 'original_name': 'Raw humidity', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_raw', 'unique_id': '/EF.111111111111/humidity/humidity_raw', @@ -2881,12 +3039,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/temperature', @@ -2935,12 +3097,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Moisture 2', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'moisture_id', 'unique_id': '/EF.111111111112/moisture/sensor.2', @@ -2989,12 +3155,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Moisture 3', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'moisture_id', 'unique_id': '/EF.111111111112/moisture/sensor.3', @@ -3049,6 +3219,7 @@ 'original_name': 'Wetness 0', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wetness_id', 'unique_id': '/EF.111111111112/moisture/sensor.0', @@ -3103,6 +3274,7 @@ 'original_name': 'Wetness 1', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wetness_id', 'unique_id': '/EF.111111111112/moisture/sensor.1', diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 8be414c7c1e..d819fdd0d54 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Programmed input-output', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio', 'unique_id': '/05.111111111111/PIO', @@ -76,6 +77,7 @@ 'original_name': 'Latch A', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/12.111111111111/latch.A', @@ -125,6 +127,7 @@ 'original_name': 'Latch B', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/12.111111111111/latch.B', @@ -174,6 +177,7 @@ 'original_name': 'Programmed input-output A', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/12.111111111111/PIO.A', @@ -223,6 +227,7 @@ 'original_name': 'Programmed input-output B', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/12.111111111111/PIO.B', @@ -272,6 +277,7 @@ 'original_name': 'Current A/D control', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'iad', 'unique_id': '/26.111111111111/IAD', @@ -321,6 +327,7 @@ 'original_name': 'Latch 0', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/29.111111111111/latch.0', @@ -370,6 +377,7 @@ 'original_name': 'Latch 1', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/29.111111111111/latch.1', @@ -419,6 +427,7 @@ 'original_name': 'Latch 2', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/29.111111111111/latch.2', @@ -468,6 +477,7 @@ 'original_name': 'Latch 3', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/29.111111111111/latch.3', @@ -517,6 +527,7 @@ 'original_name': 'Latch 4', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/29.111111111111/latch.4', @@ -566,6 +577,7 @@ 'original_name': 'Latch 5', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/29.111111111111/latch.5', @@ -615,6 +627,7 @@ 'original_name': 'Latch 6', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/29.111111111111/latch.6', @@ -664,6 +677,7 @@ 'original_name': 'Latch 7', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'latch_id', 'unique_id': '/29.111111111111/latch.7', @@ -713,6 +727,7 @@ 'original_name': 'Programmed input-output 0', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/29.111111111111/PIO.0', @@ -762,6 +777,7 @@ 'original_name': 'Programmed input-output 1', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/29.111111111111/PIO.1', @@ -811,6 +827,7 @@ 'original_name': 'Programmed input-output 2', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/29.111111111111/PIO.2', @@ -860,6 +877,7 @@ 'original_name': 'Programmed input-output 3', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/29.111111111111/PIO.3', @@ -909,6 +927,7 @@ 'original_name': 'Programmed input-output 4', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/29.111111111111/PIO.4', @@ -958,6 +977,7 @@ 'original_name': 'Programmed input-output 5', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/29.111111111111/PIO.5', @@ -1007,6 +1027,7 @@ 'original_name': 'Programmed input-output 6', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/29.111111111111/PIO.6', @@ -1056,6 +1077,7 @@ 'original_name': 'Programmed input-output 7', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/29.111111111111/PIO.7', @@ -1105,6 +1127,7 @@ 'original_name': 'Programmed input-output A', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/3A.111111111111/PIO.A', @@ -1154,6 +1177,7 @@ 'original_name': 'Programmed input-output B', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pio_id', 'unique_id': '/3A.111111111111/PIO.B', @@ -1203,6 +1227,7 @@ 'original_name': 'Current A/D control', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'iad', 'unique_id': '/A6.111111111111/IAD', @@ -1252,6 +1277,7 @@ 'original_name': 'Leaf sensor 0', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_id', 'unique_id': '/EF.111111111112/moisture/is_leaf.0', @@ -1301,6 +1327,7 @@ 'original_name': 'Leaf sensor 1', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_id', 'unique_id': '/EF.111111111112/moisture/is_leaf.1', @@ -1350,6 +1377,7 @@ 'original_name': 'Leaf sensor 2', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_id', 'unique_id': '/EF.111111111112/moisture/is_leaf.2', @@ -1399,6 +1427,7 @@ 'original_name': 'Leaf sensor 3', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_id', 'unique_id': '/EF.111111111112/moisture/is_leaf.3', @@ -1448,6 +1477,7 @@ 'original_name': 'Moisture sensor 0', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_id', 'unique_id': '/EF.111111111112/moisture/is_moisture.0', @@ -1497,6 +1527,7 @@ 'original_name': 'Moisture sensor 1', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_id', 'unique_id': '/EF.111111111112/moisture/is_moisture.1', @@ -1546,6 +1577,7 @@ 'original_name': 'Moisture sensor 2', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_id', 'unique_id': '/EF.111111111112/moisture/is_moisture.2', @@ -1595,6 +1627,7 @@ 'original_name': 'Moisture sensor 3', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_id', 'unique_id': '/EF.111111111112/moisture/is_moisture.3', @@ -1644,6 +1677,7 @@ 'original_name': 'Hub branch 0', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_id', 'unique_id': '/EF.111111111113/hub/branch.0', @@ -1693,6 +1727,7 @@ 'original_name': 'Hub branch 1', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_id', 'unique_id': '/EF.111111111113/hub/branch.1', @@ -1742,6 +1777,7 @@ 'original_name': 'Hub branch 2', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_id', 'unique_id': '/EF.111111111113/hub/branch.2', @@ -1791,6 +1827,7 @@ 'original_name': 'Hub branch 3', 'platform': 'onewire', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_id', 'unique_id': '/EF.111111111113/hub/branch.3', diff --git a/tests/components/onvif/test_diagnostics.py b/tests/components/onvif/test_diagnostics.py index ce8febe2341..ca2ba8e8c74 100644 --- a/tests/components/onvif/test_diagnostics.py +++ b/tests/components/onvif/test_diagnostics.py @@ -1,6 +1,6 @@ """Test ONVIF diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 77c28de2773..0f874969aff 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -17,13 +17,6 @@ }), 'tool_name': 'test_tool', }), - dict({ - 'id': 'call_call_2', - 'tool_args': dict({ - 'param1': 'call2', - }), - 'tool_name': 'test_tool', - }), ]), }), dict({ @@ -33,6 +26,20 @@ 'tool_name': 'test_tool', 'tool_result': 'value1', }), + dict({ + 'agent_id': 'conversation.openai', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'call_call_2', + 'tool_args': dict({ + 'param1': 'call2', + }), + 'tool_name': 'test_tool', + }), + ]), + }), dict({ 'agent_id': 'conversation.openai', 'role': 'tool_result', @@ -48,3 +55,38 @@ }), ]) # --- +# name: test_function_call_without_reasoning + list([ + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.openai', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.openai', + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.openai', + 'content': 'Cool', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 269590b483a..99559cb3b61 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -596,6 +596,48 @@ async def test_function_call( assert mock_chat_log.content[1:] == snapshot +async def test_function_call_without_reasoning( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 + snapshot: SnapshotAssertion, +) -> None: + """Test function call from the assistant.""" + mock_create_stream.return_value = [ + # Initial conversation + ( + *create_function_tool_call_item( + id="fc_1", + arguments=['{"para', 'm1":"call1"}'], + call_id="call_call_1", + name="test_tool", + output_index=1, + ), + ), + # Response after tool responses + create_message_item(id="msg_A", text="Cool", output_index=0), + ] + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + } + ) + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai", + ) + + 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 + + @pytest.mark.parametrize( ("description", "messages"), [ diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index 937540a42c1..54bab7e7ee6 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from python_opensky import StatesResponse -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.opensky.const import ( DOMAIN, diff --git a/tests/components/openweathermap/__init__.py b/tests/components/openweathermap/__init__.py index e718962766f..9552cdb4f70 100644 --- a/tests/components/openweathermap/__init__.py +++ b/tests/components/openweathermap/__init__.py @@ -1 +1,24 @@ -"""Tests for the OpenWeatherMap integration.""" +"""Shared utilities for OpenWeatherMap tests.""" + +from unittest.mock import patch + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platforms: list[Platform], +): + """Set up the OpenWeatherMap platform.""" + config_entry.add_to_hass(hass) + with ( + patch("homeassistant.components.openweathermap.PLATFORMS", platforms), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/openweathermap/conftest.py b/tests/components/openweathermap/conftest.py new file mode 100644 index 00000000000..f7de53b8f97 --- /dev/null +++ b/tests/components/openweathermap/conftest.py @@ -0,0 +1,163 @@ +"""Configure tests for the OpenWeatherMap integration.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +from pyopenweathermap import ( + AirPollutionReport, + CurrentAirPollution, + CurrentWeather, + DailyTemperature, + DailyWeatherForecast, + MinutelyWeatherForecast, + WeatherCondition, + WeatherReport, +) +from pyopenweathermap.client.owm_abstract_client import OWMClient +import pytest + +from homeassistant.components.openweathermap.const import DEFAULT_LANGUAGE, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, +) + +from tests.common import MockConfigEntry, patch + +API_KEY = "test_api_key" +LATITUDE = 12.34 +LONGITUDE = 56.78 +NAME = "openweathermap" + + +@pytest.fixture +def mode(request: pytest.FixtureRequest) -> str: + """Return mode passed in parameter.""" + return request.param + + +@pytest.fixture +def mock_config_entry(mode: str) -> MockConfigEntry: + """Fixture for creating a mock OpenWeatherMap config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: API_KEY, + CONF_LATITUDE: LATITUDE, + CONF_LONGITUDE: LONGITUDE, + CONF_NAME: NAME, + }, + options={ + CONF_MODE: mode, + CONF_LANGUAGE: DEFAULT_LANGUAGE, + }, + entry_id="test", + version=5, + unique_id=f"{LATITUDE}-{LONGITUDE}", + ) + + +@pytest.fixture +def owm_client_mock() -> Generator[AsyncMock]: + """Mock OWMClient.""" + client = AsyncMock(spec=OWMClient, autospec=True) + current_weather = CurrentWeather( + date_time=datetime.fromtimestamp(1714063536, tz=UTC), + temperature=6.84, + feels_like=2.07, + pressure=1000, + humidity=82, + dew_point=3.99, + uv_index=0.13, + cloud_coverage=75, + visibility=10000, + wind_speed=9.83, + wind_bearing=199, + wind_gust=None, + rain={"1h": 1.21}, + snow=None, + condition=WeatherCondition( + id=803, + main="Clouds", + description="broken clouds", + icon="04d", + ), + ) + daily_weather_forecast = DailyWeatherForecast( + date_time=datetime.fromtimestamp(1714063536, tz=UTC), + summary="There will be clear sky until morning, then partly cloudy", + temperature=DailyTemperature( + day=18.76, + min=8.11, + max=21.26, + night=13.06, + evening=20.51, + morning=8.47, + ), + feels_like=DailyTemperature( + day=18.76, + min=8.11, + max=21.26, + night=13.06, + evening=20.51, + morning=8.47, + ), + pressure=1015, + humidity=62, + dew_point=11.34, + wind_speed=8.14, + wind_bearing=168, + wind_gust=11.81, + condition=WeatherCondition( + id=803, + main="Clouds", + description="broken clouds", + icon="04d", + ), + cloud_coverage=84, + precipitation_probability=0, + uv_index=4.06, + rain=0, + snow=0, + ) + minutely_weather_forecast = [ + MinutelyWeatherForecast(date_time=1728672360, precipitation=0), + MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23), + MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5), + MinutelyWeatherForecast(date_time=1728672540, precipitation=0), + ] + client.get_weather.return_value = WeatherReport( + current_weather, minutely_weather_forecast, [], [daily_weather_forecast] + ) + current_air_pollution = CurrentAirPollution( + date_time=datetime.fromtimestamp(1714063537, tz=UTC), + aqi=3, + co=125.55, + no=0.11, + no2=0.78, + o3=101.98, + so2=0.59, + pm2_5=4.48, + pm10=4.77, + nh3=4.62, + ) + client.get_air_pollution.return_value = AirPollutionReport( + current_air_pollution, [] + ) + client.validate_key.return_value = True + with ( + patch( + "homeassistant.components.openweathermap.create_owm_client", + return_value=client, + ), + patch( + "homeassistant.components.openweathermap.utils.create_owm_client", + return_value=client, + ), + ): + yield client diff --git a/tests/components/openweathermap/snapshots/test_sensor.ambr b/tests/components/openweathermap/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..11a1feb721f --- /dev/null +++ b/tests/components/openweathermap/snapshots/test_sensor.ambr @@ -0,0 +1,2170 @@ +# serializer version: 1 +# name: test_sensor_states[air_pollution][sensor.openweathermap_air_quality_index-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.openweathermap_air_quality_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality index', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-aqi', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_air_quality_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'aqi', + 'friendly_name': 'openweathermap Air quality index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_air_quality_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_carbon_monoxide-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.openweathermap_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-co', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'carbon_monoxide', + 'friendly_name': 'openweathermap Carbon monoxide', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_carbon_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '125.55', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_nitrogen_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_nitrogen_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nitrogen dioxide', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-no2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'nitrogen_dioxide', + 'friendly_name': 'openweathermap Nitrogen dioxide', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_nitrogen_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.78', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_nitrogen_monoxide-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.openweathermap_nitrogen_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nitrogen monoxide', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-no', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_nitrogen_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'nitrogen_monoxide', + 'friendly_name': 'openweathermap Nitrogen monoxide', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_nitrogen_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.11', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_ozone-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.openweathermap_ozone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ozone', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-o3', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'ozone', + 'friendly_name': 'openweathermap Ozone', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_ozone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.98', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_pm10-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.openweathermap_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-pm10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'pm10', + 'friendly_name': 'openweathermap PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.77', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-pm2_5', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'pm25', + 'friendly_name': 'openweathermap PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.48', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_sulphur_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_sulphur_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sulphur dioxide', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-so2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor_states[air_pollution][sensor.openweathermap_sulphur_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'sulphur_dioxide', + 'friendly_name': 'openweathermap Sulphur dioxide', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_sulphur_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.59', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_cloud_coverage-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.openweathermap_cloud_coverage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cloud coverage', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-clouds', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Cloud coverage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_condition-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.openweathermap_condition', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Condition', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_dew_point-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.openweathermap_dew_point', + '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': 'Dew Point', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-dew_point', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Dew Point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.99', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_feels_like_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.openweathermap_feels_like_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': 'Feels like temperature', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-feels_like_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_feels_like_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Feels like temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_feels_like_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.07', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_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.openweathermap_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': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'humidity', + 'friendly_name': 'openweathermap Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '82', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_precipitation_kind-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.openweathermap_precipitation_kind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation kind', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-precipitation_kind', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_precipitation_kind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Precipitation kind', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_precipitation_kind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Rain', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_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.openweathermap_pressure', + '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': 'Pressure', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'pressure', + 'friendly_name': 'openweathermap Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_rain-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.openweathermap_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'openweathermap Rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.21', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_snow-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.openweathermap_snow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Snow', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-snow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_snow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'openweathermap Snow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_snow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_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.openweathermap_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.84', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_uv_index-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.openweathermap_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV Index', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-uv_index', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap UV Index', + 'state_class': , + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.13', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_visibility-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.openweathermap_visibility', + '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': 'Visibility', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-visibility_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_visibility-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'distance', + 'friendly_name': 'openweathermap Visibility', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_visibility', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10000', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_weather-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.openweathermap_weather', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weather', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-weather', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_weather-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Weather', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_weather', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'broken clouds', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_weather_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.openweathermap_weather_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': 'Weather Code', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-weather_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_weather_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Weather Code', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_weather_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '803', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_bearing-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.openweathermap_wind_bearing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind bearing', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_bearing', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_bearing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_direction', + 'friendly_name': 'openweathermap Wind bearing', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_bearing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '199', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_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': None, + 'entity_id': 'sensor.openweathermap_wind_speed', + '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': 'Wind speed', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.388', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_cloud_coverage-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.openweathermap_cloud_coverage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cloud coverage', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-clouds', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Cloud coverage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_condition-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.openweathermap_condition', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Condition', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_dew_point-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.openweathermap_dew_point', + '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': 'Dew Point', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-dew_point', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Dew Point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.99', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_feels_like_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.openweathermap_feels_like_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': 'Feels like temperature', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-feels_like_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_feels_like_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Feels like temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_feels_like_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.07', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_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.openweathermap_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': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'humidity', + 'friendly_name': 'openweathermap Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '82', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_precipitation_kind-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.openweathermap_precipitation_kind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation kind', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-precipitation_kind', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_precipitation_kind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Precipitation kind', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_precipitation_kind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Rain', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_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.openweathermap_pressure', + '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': 'Pressure', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'pressure', + 'friendly_name': 'openweathermap Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_rain-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.openweathermap_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'openweathermap Rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.21', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_snow-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.openweathermap_snow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Snow', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-snow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_snow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'openweathermap Snow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_snow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_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.openweathermap_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.84', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_uv_index-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.openweathermap_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV Index', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-uv_index', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap UV Index', + 'state_class': , + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.13', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_visibility-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.openweathermap_visibility', + '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': 'Visibility', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-visibility_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_visibility-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'distance', + 'friendly_name': 'openweathermap Visibility', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_visibility', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10000', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_weather-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.openweathermap_weather', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weather', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-weather', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_weather-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Weather', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_weather', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'broken clouds', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_weather_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.openweathermap_weather_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': 'Weather Code', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-weather_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_weather_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Weather Code', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_weather_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '803', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_bearing-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.openweathermap_wind_bearing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind bearing', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_bearing', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_bearing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_direction', + 'friendly_name': 'openweathermap Wind bearing', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_bearing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '199', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_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': None, + 'entity_id': 'sensor.openweathermap_wind_speed', + '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': 'Wind speed', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.388', + }) +# --- diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index c89dcb96a9c..760160a96f4 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_get_minute_forecast[mock_service_response] +# name: test_get_minute_forecast[v3.0][mock_service_response] dict({ 'weather.openweathermap': dict({ 'forecast': list([ @@ -23,3 +23,191 @@ }), }) # --- +# name: test_weather_states[current][weather.openweathermap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.openweathermap', + 'has_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': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_states[current][weather.openweathermap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 2.1, + 'attribution': 'Data provided by OpenWeatherMap', + 'cloud_coverage': 75, + 'dew_point': 4.0, + 'friendly_name': 'openweathermap', + 'humidity': 82, + 'precipitation_unit': , + 'pressure': 1000.0, + 'pressure_unit': , + 'temperature': 6.8, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 199, + 'wind_speed': 35.39, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.openweathermap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- +# name: test_weather_states[forecast][weather.openweathermap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.openweathermap', + 'has_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': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12.34-56.78', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_states[forecast][weather.openweathermap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 2.1, + 'attribution': 'Data provided by OpenWeatherMap', + 'cloud_coverage': 75, + 'dew_point': 4.0, + 'friendly_name': 'openweathermap', + 'humidity': 82, + 'precipitation_unit': , + 'pressure': 1000.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 6.8, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 199, + 'wind_speed': 35.39, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.openweathermap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- +# name: test_weather_states[v3.0][weather.openweathermap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.openweathermap', + 'has_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': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12.34-56.78', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_states[v3.0][weather.openweathermap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 2.1, + 'attribution': 'Data provided by OpenWeatherMap', + 'cloud_coverage': 75, + 'dew_point': 4.0, + 'friendly_name': 'openweathermap', + 'humidity': 82, + 'precipitation_unit': , + 'pressure': 1000.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 6.8, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 199, + 'wind_speed': 35.39, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.openweathermap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index d5e01677dd8..0315ca91010 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -1,17 +1,8 @@ """Define tests for the OpenWeatherMap config flow.""" -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock -from pyopenweathermap import ( - CurrentWeather, - DailyTemperature, - DailyWeatherForecast, - MinutelyWeatherForecast, - RequestError, - WeatherCondition, - WeatherReport, -) +from pyopenweathermap import RequestError import pytest from homeassistant.components.openweathermap.const import ( @@ -32,13 +23,15 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import LATITUDE, LONGITUDE + from tests.common import MockConfigEntry CONFIG = { CONF_NAME: "openweathermap", CONF_API_KEY: "foo", - CONF_LATITUDE: 50, - CONF_LONGITUDE: 40, + CONF_LATITUDE: LATITUDE, + CONF_LONGITUDE: LONGITUDE, CONF_LANGUAGE: DEFAULT_LANGUAGE, CONF_MODE: OWM_MODE_V30, } @@ -46,118 +39,11 @@ CONFIG = { VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} -def _create_static_weather_report() -> WeatherReport: - """Create a static WeatherReport.""" - - current_weather = CurrentWeather( - date_time=datetime.fromtimestamp(1714063536, tz=UTC), - temperature=6.84, - feels_like=2.07, - pressure=1000, - humidity=82, - dew_point=3.99, - uv_index=0.13, - cloud_coverage=75, - visibility=10000, - wind_speed=9.83, - wind_bearing=199, - wind_gust=None, - rain={"1h": 1.21}, - snow=None, - condition=WeatherCondition( - id=803, - main="Clouds", - description="broken clouds", - icon="04d", - ), - ) - daily_weather_forecast = DailyWeatherForecast( - date_time=datetime.fromtimestamp(1714063536, tz=UTC), - summary="There will be clear sky until morning, then partly cloudy", - temperature=DailyTemperature( - day=18.76, - min=8.11, - max=21.26, - night=13.06, - evening=20.51, - morning=8.47, - ), - feels_like=DailyTemperature( - day=18.76, - min=8.11, - max=21.26, - night=13.06, - evening=20.51, - morning=8.47, - ), - pressure=1015, - humidity=62, - dew_point=11.34, - wind_speed=8.14, - wind_bearing=168, - wind_gust=11.81, - condition=WeatherCondition( - id=803, - main="Clouds", - description="broken clouds", - icon="04d", - ), - cloud_coverage=84, - precipitation_probability=0, - uv_index=4.06, - rain=0, - snow=0, - ) - minutely_weather_forecast = [ - MinutelyWeatherForecast(date_time=1728672360, precipitation=0), - MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23), - MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5), - MinutelyWeatherForecast(date_time=1728672540, precipitation=0), - ] - return WeatherReport( - current_weather, minutely_weather_forecast, [], [daily_weather_forecast] - ) - - -def _create_mocked_owm_factory(is_valid: bool): - """Create a mocked OWM client.""" - - weather_report = _create_static_weather_report() - mocked_owm_client = MagicMock() - mocked_owm_client.validate_key = AsyncMock(return_value=is_valid) - mocked_owm_client.get_weather = AsyncMock(return_value=weather_report) - - return mocked_owm_client - - -@pytest.fixture(name="owm_client_mock") -def mock_owm_client(): - """Mock config_flow OWMClient.""" - with patch( - "homeassistant.components.openweathermap.create_owm_client", - ) as mock: - yield mock - - -@pytest.fixture(name="config_flow_owm_client_mock") -def mock_config_flow_owm_client(): - """Mock config_flow OWMClient.""" - with patch( - "homeassistant.components.openweathermap.utils.create_owm_client", - ) as mock: - yield mock - - async def test_successful_config_flow( hass: HomeAssistant, - owm_client_mock, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, ) -> None: """Test that the form is served with valid input.""" - mock = _create_mocked_owm_factory(True) - owm_client_mock.return_value = mock - config_flow_owm_client_mock.return_value = mock - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -187,39 +73,32 @@ async def test_successful_config_flow( assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] +@pytest.mark.parametrize("mode", [OWM_MODE_V30], indirect=True) async def test_abort_config_flow( hass: HomeAssistant, - owm_client_mock, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test that the form is served with same data.""" - mock = _create_mocked_owm_factory(True) - owm_client_mock.return_value = mock - config_flow_owm_client_mock.return_value = mock - + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} ) - await hass.async_block_till_done() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) - await hass.async_block_till_done() + 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"], CONFIG) assert result["type"] is FlowResultType.ABORT async def test_config_flow_options_change( hass: HomeAssistant, - owm_client_mock, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, ) -> None: """Test that the options form.""" - mock = _create_mocked_owm_factory(True) - owm_client_mock.return_value = mock - config_flow_owm_client_mock.return_value = mock - config_entry = MockConfigEntry( domain=DOMAIN, unique_id="openweathermap_unique_id", data=CONFIG ) @@ -274,10 +153,10 @@ async def test_config_flow_options_change( async def test_form_invalid_api_key( hass: HomeAssistant, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, ) -> None: """Test that the form is served with no input.""" - config_flow_owm_client_mock.return_value = _create_mocked_owm_factory(False) + owm_client_mock.validate_key.return_value = False result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG ) @@ -285,7 +164,7 @@ async def test_form_invalid_api_key( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_api_key"} - config_flow_owm_client_mock.return_value = _create_mocked_owm_factory(True) + owm_client_mock.validate_key.return_value = True result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) @@ -295,11 +174,10 @@ async def test_form_invalid_api_key( async def test_form_api_call_error( hass: HomeAssistant, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, ) -> None: """Test setting up with api call error.""" - config_flow_owm_client_mock.return_value = _create_mocked_owm_factory(True) - config_flow_owm_client_mock.side_effect = RequestError("oops") + owm_client_mock.validate_key.side_effect = RequestError("oops") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG ) @@ -307,7 +185,7 @@ async def test_form_api_call_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - config_flow_owm_client_mock.side_effect = None + owm_client_mock.validate_key.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) diff --git a/tests/components/openweathermap/test_sensor.py b/tests/components/openweathermap/test_sensor.py new file mode 100644 index 00000000000..78d45bbcc47 --- /dev/null +++ b/tests/components/openweathermap/test_sensor.py @@ -0,0 +1,52 @@ +"""Tests for OpenWeatherMap sensors.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.openweathermap.const import ( + OWM_MODE_AIRPOLLUTION, + OWM_MODE_FREE_CURRENT, + OWM_MODE_FREE_FORECAST, + OWM_MODE_V30, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mode", [OWM_MODE_V30, OWM_MODE_FREE_CURRENT, OWM_MODE_AIRPOLLUTION], indirect=True +) +async def test_sensor_states( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, + mode: str, +) -> None: + """Test sensor states are correctly collected from library with different modes and mocked function responses.""" + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("mode", [OWM_MODE_FREE_FORECAST], indirect=True) +async def test_mode_no_sensor( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, + mode: str, +) -> None: + """Test modes that do not provide any sensor.""" + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + assert len(entity_registry.entities) == 0 diff --git a/tests/components/openweathermap/test_weather.py b/tests/components/openweathermap/test_weather.py index e9817e739ac..0d7dfcad71f 100644 --- a/tests/components/openweathermap/test_weather.py +++ b/tests/components/openweathermap/test_weather.py @@ -1,91 +1,40 @@ """Test the OpenWeatherMap weather entity.""" +from unittest.mock import MagicMock + import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.openweathermap.const import ( - DEFAULT_LANGUAGE, DOMAIN, OWM_MODE_FREE_CURRENT, + OWM_MODE_FREE_FORECAST, OWM_MODE_V30, ) from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_API_KEY, - CONF_LANGUAGE, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MODE, - CONF_NAME, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er -from .test_config_flow import _create_static_weather_report +from . import setup_platform -from tests.common import AsyncMock, MockConfigEntry, patch +from tests.common import MockConfigEntry, snapshot_platform ENTITY_ID = "weather.openweathermap" -API_KEY = "test_api_key" -LATITUDE = 12.34 -LONGITUDE = 56.78 -NAME = "openweathermap" - -# Define test data for mocked weather report -static_weather_report = _create_static_weather_report() -def mock_config_entry(mode: str) -> MockConfigEntry: - """Create a mock OpenWeatherMap config entry.""" - return MockConfigEntry( - domain=DOMAIN, - data={ - CONF_API_KEY: API_KEY, - CONF_LATITUDE: LATITUDE, - CONF_LONGITUDE: LONGITUDE, - CONF_NAME: NAME, - }, - options={CONF_MODE: mode, CONF_LANGUAGE: DEFAULT_LANGUAGE}, - version=5, - ) - - -@pytest.fixture -def mock_config_entry_free_current() -> MockConfigEntry: - """Create a mock OpenWeatherMap FREE_CURRENT config entry.""" - return mock_config_entry(OWM_MODE_FREE_CURRENT) - - -@pytest.fixture -def mock_config_entry_v30() -> MockConfigEntry: - """Create a mock OpenWeatherMap v3.0 config entry.""" - return mock_config_entry(OWM_MODE_V30) - - -async def setup_mock_config_entry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -): - """Set up the MockConfigEntry and assert it is loaded correctly.""" - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID) - assert mock_config_entry.state is ConfigEntryState.LOADED - - -@patch( - "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", - AsyncMock(return_value=static_weather_report), -) +@pytest.mark.parametrize("mode", [OWM_MODE_V30], indirect=True) async def test_get_minute_forecast( hass: HomeAssistant, - mock_config_entry_v30: MockConfigEntry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, + mode: str, ) -> None: """Test the get_minute_forecast Service call.""" - await setup_mock_config_entry(hass, mock_config_entry_v30) + await setup_platform(hass, mock_config_entry, [Platform.WEATHER]) result = await hass.services.async_call( DOMAIN, SERVICE_GET_MINUTE_FORECAST, @@ -96,18 +45,19 @@ async def test_get_minute_forecast( assert result == snapshot(name="mock_service_response") -@patch( - "pyopenweathermap.client.free_client.OWMFreeClient.get_weather", - AsyncMock(return_value=static_weather_report), +@pytest.mark.parametrize( + "mode", [OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST], indirect=True ) -async def test_mode_fail( +async def test_get_minute_forecast_unavailable( hass: HomeAssistant, - mock_config_entry_free_current: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, + mode: str, ) -> None: """Test that Minute forecasting fails when mode is not v3.0.""" - await setup_mock_config_entry(hass, mock_config_entry_free_current) - # Expect a ServiceValidationError when mode is not OWM_MODE_V30 + await setup_platform(hass, mock_config_entry, [Platform.WEATHER]) with pytest.raises( ServiceValidationError, match="Minute forecast is available only when OpenWeatherMap mode is set to v3.0", @@ -119,3 +69,19 @@ async def test_mode_fail( blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + "mode", [OWM_MODE_V30, OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST], indirect=True +) +async def test_weather_states( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, +) -> None: + """Test weather states are correctly collected from library with different modes and mocked function responses.""" + + await setup_platform(hass, mock_config_entry, [Platform.WEATHER]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/osoenergy/snapshots/test_water_heater.ambr b/tests/components/osoenergy/snapshots/test_water_heater.ambr index 92b3a7aa099..18c434d133b 100644 --- a/tests/components/osoenergy/snapshots/test_water_heater.ambr +++ b/tests/components/osoenergy/snapshots/test_water_heater.ambr @@ -30,6 +30,7 @@ 'original_name': None, 'platform': 'osoenergy', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'osoenergy_water_heater', diff --git a/tests/components/osoenergy/test_water_heater.py b/tests/components/osoenergy/test_water_heater.py index 851e710fa1c..fd27975c938 100644 --- a/tests/components/osoenergy/test_water_heater.py +++ b/tests/components/osoenergy/test_water_heater.py @@ -3,7 +3,7 @@ from unittest.mock import ANY, MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.osoenergy.const import DOMAIN from homeassistant.components.osoenergy.water_heater import ( diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 5c98b4e9260..410c2ebb5f1 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -82,21 +82,21 @@ async def test_form_cloud(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -105,7 +105,7 @@ async def test_form_cloud(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N return_value=MOCK_GATEWAY_RESPONSE, ), ): - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) @@ -125,13 +125,13 @@ async def test_form_only_cloud_supported( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER2}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -140,7 +140,7 @@ async def test_form_only_cloud_supported( return_value=MOCK_GATEWAY_RESPONSE, ), ): - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) @@ -160,28 +160,28 @@ async def test_form_local_happy_flow( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "local"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local" with patch.multiple( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "gateway-1234-5678-1234.local:8443", @@ -192,9 +192,9 @@ async def test_form_local_happy_flow( 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"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "gateway-1234-5678-1234.local:8443" + assert result["data"] == { "host": "gateway-1234-5678-1234.local:8443", "token": TEST_TOKEN, "verify_ssl": True, @@ -227,32 +227,32 @@ async def test_form_invalid_auth_cloud( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) await hass.async_block_till_done() - assert result4["type"] is FlowResultType.FORM - assert result4["errors"] == {"base": error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} @pytest.mark.parametrize( @@ -283,24 +283,24 @@ async def test_form_invalid_auth_local( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "local"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local" with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": TEST_HOST, @@ -311,8 +311,8 @@ async def test_form_invalid_auth_local( await hass.async_block_till_done() - assert result4["type"] is FlowResultType.FORM - assert result4["errors"] == {"base": error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} @pytest.mark.parametrize( @@ -331,25 +331,25 @@ async def test_form_invalid_cozytouch_auth( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER_COZYTOUCH}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": error} - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + assert result["step_id"] == "cloud" async def test_cloud_abort_on_duplicate_entry( @@ -369,21 +369,21 @@ async def test_cloud_abort_on_duplicate_entry( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -392,13 +392,13 @@ async def test_cloud_abort_on_duplicate_entry( return_value=MOCK_GATEWAY_RESPONSE, ), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_local_abort_on_duplicate_entry( @@ -425,21 +425,21 @@ async def test_local_abort_on_duplicate_entry( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "local"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local" with patch.multiple( "pyoverkiz.client.OverkizClient", @@ -447,7 +447,7 @@ async def test_local_abort_on_duplicate_entry( get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), get_setup_option=AsyncMock(return_value=True), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": TEST_HOST, @@ -456,8 +456,8 @@ async def test_local_abort_on_duplicate_entry( }, ) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_cloud_allow_multiple_unique_entries( @@ -478,21 +478,21 @@ async def test_cloud_allow_multiple_unique_entries( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -501,14 +501,14 @@ async def test_cloud_allow_multiple_unique_entries( return_value=MOCK_GATEWAY_RESPONSE, ), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == TEST_EMAIL - assert result4["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL + assert result["data"] == { "api_type": "cloud", "username": TEST_EMAIL, "password": TEST_PASSWORD, @@ -544,7 +544,7 @@ async def test_cloud_reauth_success(hass: HomeAssistant) -> None: return_value=MOCK_GATEWAY_RESPONSE, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ "username": TEST_EMAIL, @@ -552,8 +552,8 @@ async def test_cloud_reauth_success(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_entry.data["username"] == TEST_EMAIL assert mock_entry.data["password"] == TEST_PASSWORD2 @@ -586,7 +586,7 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: return_value=MOCK_GATEWAY2_RESPONSE, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ "username": TEST_EMAIL, @@ -594,8 +594,8 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_wrong_account" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_wrong_account" async def test_local_reauth_legacy(hass: HomeAssistant) -> None: @@ -759,15 +759,15 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) @@ -776,7 +776,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch("pyoverkiz.client.OverkizClient.get_gateways", return_value=None), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": TEST_EMAIL, @@ -784,9 +784,9 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == TEST_EMAIL - assert result4["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL + assert result["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER, @@ -830,21 +830,21 @@ async def test_zeroconf_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -853,14 +853,14 @@ async def test_zeroconf_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) - return_value=MOCK_GATEWAY_RESPONSE, ), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == TEST_EMAIL - assert result4["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL + assert result["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER, @@ -883,28 +883,28 @@ async def test_local_zeroconf_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - result2 = await hass.config_entries.flow.async_configure( + result = 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" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "local"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local" with patch.multiple( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "gateway-1234-5678-9123.local:8443", @@ -913,11 +913,11 @@ async def test_local_zeroconf_flow( }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "gateway-1234-5678-9123.local:8443" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "gateway-1234-5678-9123.local:8443" # Verify no username/password in data - assert result4["data"] == { + assert result["data"] == { "host": "gateway-1234-5678-9123.local:8443", "token": TEST_TOKEN, "verify_ssl": False, diff --git a/tests/components/overkiz/test_diagnostics.py b/tests/components/overkiz/test_diagnostics.py index 672370c2667..e052818daee 100644 --- a/tests/components/overkiz/test_diagnostics.py +++ b/tests/components/overkiz/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.overkiz.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/overseerr/snapshots/test_event.ambr b/tests/components/overseerr/snapshots/test_event.ambr index 8a7be6c463d..bfa03d9a2e8 100644 --- a/tests/components/overseerr/snapshots/test_event.ambr +++ b/tests/components/overseerr/snapshots/test_event.ambr @@ -36,6 +36,7 @@ 'original_name': 'Last media event', 'platform': 'overseerr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_media_event', 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-media', diff --git a/tests/components/overseerr/snapshots/test_sensor.ambr b/tests/components/overseerr/snapshots/test_sensor.ambr index bbee260b782..44613d6117c 100644 --- a/tests/components/overseerr/snapshots/test_sensor.ambr +++ b/tests/components/overseerr/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Available requests', 'platform': 'overseerr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'available_requests', 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-available_requests', @@ -80,6 +81,7 @@ 'original_name': 'Declined requests', 'platform': 'overseerr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'declined_requests', 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-declined_requests', @@ -131,6 +133,7 @@ 'original_name': 'Movie requests', 'platform': 'overseerr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'movie_requests', 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-movie_requests', @@ -182,6 +185,7 @@ 'original_name': 'Pending requests', 'platform': 'overseerr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pending_requests', 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-pending_requests', @@ -233,6 +237,7 @@ 'original_name': 'Processing requests', 'platform': 'overseerr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'processing_requests', 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-processing_requests', @@ -284,6 +289,7 @@ 'original_name': 'Total requests', 'platform': 'overseerr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_requests', 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-total_requests', @@ -335,6 +341,7 @@ 'original_name': 'TV requests', 'platform': 'overseerr', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tv_requests', 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-tv_requests', diff --git a/tests/components/overseerr/test_diagnostics.py b/tests/components/overseerr/test_diagnostics.py index 28b97e9514f..394799a277c 100644 --- a/tests/components/overseerr/test_diagnostics.py +++ b/tests/components/overseerr/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/overseerr/test_event.py b/tests/components/overseerr/test_event.py index 3866ccc09ca..448cac7c5c1 100644 --- a/tests/components/overseerr/test_event.py +++ b/tests/components/overseerr/test_event.py @@ -7,7 +7,7 @@ from freezegun.api import FrozenDateTimeFactory from future.backports.datetime import timedelta import pytest from python_overseerr import OverseerrConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 6418e2103db..66e6a5c134c 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch import pytest from python_overseerr import OverseerrAuthenticationError, OverseerrConnectionError from python_overseerr.models import WebhookNotificationOptions -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import cloud from homeassistant.components.cloud import CloudNotAvailable diff --git a/tests/components/overseerr/test_sensor.py b/tests/components/overseerr/test_sensor.py index 6689b1ebcc3..2350f1b0883 100644 --- a/tests/components/overseerr/test_sensor.py +++ b/tests/components/overseerr/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index a0b87b5deef..3d7bcc3577f 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock import pytest from python_overseerr import OverseerrConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( ATTR_CONFIG_ENTRY_ID, diff --git a/tests/components/p1_monitor/test_init.py b/tests/components/p1_monitor/test_init.py index 3b7426051d4..a8ce2646034 100644 --- a/tests/components/p1_monitor/test_init.py +++ b/tests/components/p1_monitor/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from p1monitor import P1MonitorConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.p1_monitor.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/palazzetti/conftest.py b/tests/components/palazzetti/conftest.py index d3694653cd4..ab1e6323247 100644 --- a/tests/components/palazzetti/conftest.py +++ b/tests/components/palazzetti/conftest.py @@ -65,6 +65,7 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.has_fan_auto = True mock_client.has_on_off_switch = True mock_client.has_pellet_level = False + mock_client.host = "XXXXXXXXXX" mock_client.connected = True mock_client.status = 6 mock_client.is_heating = True diff --git a/tests/components/palazzetti/snapshots/test_button.ambr b/tests/components/palazzetti/snapshots/test_button.ambr index 8130f0a0ec7..bc711cd8cde 100644 --- a/tests/components/palazzetti/snapshots/test_button.ambr +++ b/tests/components/palazzetti/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Silent', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'silent', 'unique_id': '11:22:33:44:55:66-silent', diff --git a/tests/components/palazzetti/snapshots/test_climate.ambr b/tests/components/palazzetti/snapshots/test_climate.ambr index cf23cb87ccb..4ef71fe4e57 100644 --- a/tests/components/palazzetti/snapshots/test_climate.ambr +++ b/tests/components/palazzetti/snapshots/test_climate.ambr @@ -44,6 +44,7 @@ 'original_name': None, 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'palazzetti', 'unique_id': '11:22:33:44:55:66', diff --git a/tests/components/palazzetti/snapshots/test_number.ambr b/tests/components/palazzetti/snapshots/test_number.ambr index 1d40e9e4b6b..c700f08a69c 100644 --- a/tests/components/palazzetti/snapshots/test_number.ambr +++ b/tests/components/palazzetti/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Combustion power', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'combustion_power', 'unique_id': '11:22:33:44:55:66-combustion_power', @@ -89,6 +90,7 @@ 'original_name': 'Left fan speed', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan_left_speed', 'unique_id': '11:22:33:44:55:66-fan_left_speed', @@ -146,6 +148,7 @@ 'original_name': 'Right fan speed', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan_right_speed', 'unique_id': '11:22:33:44:55:66-fan_right_speed', diff --git a/tests/components/palazzetti/snapshots/test_sensor.ambr b/tests/components/palazzetti/snapshots/test_sensor.ambr index 6bf4f68c1fa..3221430fd23 100644 --- a/tests/components/palazzetti/snapshots/test_sensor.ambr +++ b/tests/components/palazzetti/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Air outlet temperature', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_outlet_temperature', 'unique_id': '11:22:33:44:55:66-air_outlet_temperature', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hydro temperature 1', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 't1_hydro', 'unique_id': '11:22:33:44:55:66-t1_hydro', @@ -127,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hydro temperature 2', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 't2_hydro', 'unique_id': '11:22:33:44:55:66-t2_hydro', @@ -179,12 +191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pellet quantity', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pellet_quantity', 'unique_id': '11:22:33:44:55:66-pellet_quantity', @@ -231,12 +247,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Return water temperature', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'return_water_temperature', 'unique_id': '11:22:33:44:55:66-return_water_temperature', @@ -283,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Room temperature', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'room_temperature', 'unique_id': '11:22:33:44:55:66-room_temperature', @@ -389,6 +413,7 @@ 'original_name': 'Status', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', 'unique_id': '11:22:33:44:55:66-status', @@ -482,12 +507,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Tank water temperature', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tank_water_temperature', 'unique_id': '11:22:33:44:55:66-tank_water_temperature', @@ -534,12 +563,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Wood combustion temperature', 'platform': 'palazzetti', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wood_combustion_temperature', 'unique_id': '11:22:33:44:55:66-wood_combustion_temperature', diff --git a/tests/components/palazzetti/test_button.py b/tests/components/palazzetti/test_button.py index de0f26fe8aa..85fd63d45d5 100644 --- a/tests/components/palazzetti/test_button.py +++ b/tests/components/palazzetti/test_button.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from pypalazzetti.exceptions import CommunicationError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/palazzetti/test_climate.py b/tests/components/palazzetti/test_climate.py index 22bd04f234e..d2aa17e71b3 100644 --- a/tests/components/palazzetti/test_climate.py +++ b/tests/components/palazzetti/test_climate.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from pypalazzetti.exceptions import CommunicationError, ValidationError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, diff --git a/tests/components/palazzetti/test_diagnostics.py b/tests/components/palazzetti/test_diagnostics.py index 80d021be511..e25ad7b9c6e 100644 --- a/tests/components/palazzetti/test_diagnostics.py +++ b/tests/components/palazzetti/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Palazzetti diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/palazzetti/test_init.py b/tests/components/palazzetti/test_init.py index 710144b2b7b..3002de1a0d2 100644 --- a/tests/components/palazzetti/test_init.py +++ b/tests/components/palazzetti/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/palazzetti/test_number.py b/tests/components/palazzetti/test_number.py index 8f09384c1b7..6483834e190 100644 --- a/tests/components/palazzetti/test_number.py +++ b/tests/components/palazzetti/test_number.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from pypalazzetti.exceptions import CommunicationError, ValidationError from pypalazzetti.fan import FanType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/palazzetti/test_sensor.py b/tests/components/palazzetti/test_sensor.py index c7d7317bb0b..55889692203 100644 --- a/tests/components/palazzetti/test_sensor.py +++ b/tests/components/palazzetti/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/pandora/__init__.py b/tests/components/pandora/__init__.py new file mode 100644 index 00000000000..6fccecfd679 --- /dev/null +++ b/tests/components/pandora/__init__.py @@ -0,0 +1 @@ +"""Padora component tests.""" diff --git a/tests/components/pandora/test_media_player.py b/tests/components/pandora/test_media_player.py new file mode 100644 index 00000000000..2af72ba2224 --- /dev/null +++ b/tests/components/pandora/test_media_player.py @@ -0,0 +1,31 @@ +"""Pandora media player tests.""" + +from homeassistant.components.media_player import DOMAIN as PLATFORM_DOMAIN +from homeassistant.components.pandora import DOMAIN as PANDORA_DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + PLATFORM_DOMAIN, + { + PLATFORM_DOMAIN: [ + { + CONF_PLATFORM: PANDORA_DOMAIN, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{PANDORA_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/paperless_ngx/__init__.py b/tests/components/paperless_ngx/__init__.py new file mode 100644 index 00000000000..f1900bf4f8e --- /dev/null +++ b/tests/components/paperless_ngx/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Paperless-ngx 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 Paperless-ngx 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/paperless_ngx/conftest.py b/tests/components/paperless_ngx/conftest.py new file mode 100644 index 00000000000..c57246eecf0 --- /dev/null +++ b/tests/components/paperless_ngx/conftest.py @@ -0,0 +1,93 @@ +"""Common fixtures for the Paperless-ngx tests.""" + +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from pypaperless.models import Statistic, Status +import pytest + +from homeassistant.components.paperless_ngx.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import USER_INPUT_ONE + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_status_data() -> Generator[MagicMock]: + """Return test status data.""" + return json.loads(load_fixture("test_data_status.json", DOMAIN)) + + +@pytest.fixture +def mock_statistic_data() -> Generator[MagicMock]: + """Return test statistic data.""" + return json.loads(load_fixture("test_data_statistic.json", DOMAIN)) + + +@pytest.fixture +def mock_statistic_data_update() -> Generator[MagicMock]: + """Return updated test statistic data.""" + return json.loads(load_fixture("test_data_statistic_update.json", DOMAIN)) + + +@pytest.fixture(autouse=True) +def mock_paperless( + mock_statistic_data: MagicMock, mock_status_data: MagicMock +) -> Generator[AsyncMock]: + """Mock the pypaperless.Paperless client.""" + with ( + patch( + "homeassistant.components.paperless_ngx.coordinator.Paperless", + autospec=True, + ) as paperless_mock, + patch( + "homeassistant.components.paperless_ngx.config_flow.Paperless", + new=paperless_mock, + ), + patch( + "homeassistant.components.paperless_ngx.Paperless", + new=paperless_mock, + ), + ): + paperless = paperless_mock.return_value + + paperless.base_url = "http://paperless.example.com/" + paperless.host_version = "2.3.0" + paperless.initialize.return_value = None + paperless.statistics = AsyncMock( + return_value=Statistic.create_with_data( + paperless, data=mock_statistic_data, fetched=True + ) + ) + paperless.status = AsyncMock( + return_value=Status.create_with_data( + paperless, data=mock_status_data, fetched=True + ) + ) + + yield paperless + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + entry_id="0KLG00V55WEVTJ0CJHM0GADNGH", + title="Paperless-ngx", + domain=DOMAIN, + data=USER_INPUT_ONE, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_paperless: MagicMock +) -> MockConfigEntry: + """Set up the Paperless-ngx integration for testing.""" + await setup_integration(hass, mock_config_entry) + + return mock_config_entry diff --git a/tests/components/paperless_ngx/const.py b/tests/components/paperless_ngx/const.py new file mode 100644 index 00000000000..addfd54a001 --- /dev/null +++ b/tests/components/paperless_ngx/const.py @@ -0,0 +1,15 @@ +"""Constants for the Paperless NGX integration tests.""" + +from homeassistant.const import CONF_API_KEY, CONF_URL + +USER_INPUT_ONE = { + CONF_URL: "https://192.168.69.16:8000", + CONF_API_KEY: "12345678", +} + +USER_INPUT_TWO = { + CONF_URL: "https://paperless.example.de", + CONF_API_KEY: "87654321", +} + +USER_INPUT_REAUTH = {CONF_API_KEY: "192837465"} diff --git a/tests/components/paperless_ngx/fixtures/test_data_statistic.json b/tests/components/paperless_ngx/fixtures/test_data_statistic.json new file mode 100644 index 00000000000..29ba93d848b --- /dev/null +++ b/tests/components/paperless_ngx/fixtures/test_data_statistic.json @@ -0,0 +1,16 @@ +{ + "documents_total": 999, + "documents_inbox": 9, + "inbox_tag": 9, + "inbox_tags": [9], + "document_file_type_counts": [ + { "mime_type": "application/pdf", "mime_type_count": 998 }, + { "mime_type": "image/png", "mime_type_count": 1 } + ], + "character_count": 99999, + "tag_count": 99, + "correspondent_count": 99, + "document_type_count": 99, + "storage_path_count": 9, + "current_asn": 99 +} diff --git a/tests/components/paperless_ngx/fixtures/test_data_statistic_update.json b/tests/components/paperless_ngx/fixtures/test_data_statistic_update.json new file mode 100644 index 00000000000..15c82365a7c --- /dev/null +++ b/tests/components/paperless_ngx/fixtures/test_data_statistic_update.json @@ -0,0 +1,16 @@ +{ + "documents_total": 420, + "documents_inbox": 3, + "inbox_tag": 5, + "inbox_tags": [2], + "document_file_type_counts": [ + { "mime_type": "application/pdf", "mime_type_count": 419 }, + { "mime_type": "image/png", "mime_type_count": 1 } + ], + "character_count": 324234, + "tag_count": 43, + "correspondent_count": 9659, + "document_type_count": 54656, + "storage_path_count": 6459, + "current_asn": 959 +} diff --git a/tests/components/paperless_ngx/fixtures/test_data_status.json b/tests/components/paperless_ngx/fixtures/test_data_status.json new file mode 100644 index 00000000000..9a4ffc25cd0 --- /dev/null +++ b/tests/components/paperless_ngx/fixtures/test_data_status.json @@ -0,0 +1,36 @@ +{ + "pngx_version": "2.15.3", + "server_os": "Linux-6.6.74-haos-raspi-aarch64-with-glibc2.36", + "install_type": "docker", + "storage": { + "total": 62101651456, + "available": 25376927744 + }, + "database": { + "type": "sqlite", + "url": "/config/data/db.sqlite3", + "status": "OK", + "error": null, + "migration_status": { + "latest_migration": "paperless_mail.0029_mailrule_pdf_layout", + "unapplied_migrations": [] + } + }, + "tasks": { + "redis_url": "redis://localhost:6379", + "redis_status": "OK", + "redis_error": null, + "celery_status": "OK", + "celery_url": "celery@ca5234a0-paperless-ngx", + "celery_error": null, + "index_status": "OK", + "index_last_modified": "2025-05-25T00:00:27.053090+02:00", + "index_error": null, + "classifier_status": "OK", + "classifier_last_trained": "2025-05-25T15:05:15.824671Z", + "classifier_error": null, + "sanity_check_status": "OK", + "sanity_check_last_run": "2025-05-24T22:30:21.005536Z", + "sanity_check_error": null + } +} diff --git a/tests/components/paperless_ngx/snapshots/test_diagnostics.ambr b/tests/components/paperless_ngx/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..778d10d3d1b --- /dev/null +++ b/tests/components/paperless_ngx/snapshots/test_diagnostics.ambr @@ -0,0 +1,86 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'data': dict({ + 'statistics': dict({ + 'character_count': 99999, + 'correspondent_count': 99, + 'current_asn': 99, + 'document_file_type_counts': list([ + dict({ + 'mime_type': 'application/pdf', + 'mime_type_count': 998, + }), + dict({ + 'mime_type': 'image/png', + 'mime_type_count': 1, + }), + ]), + 'document_type_count': 99, + 'documents_inbox': 9, + 'documents_total': 999, + 'inbox_tag': 9, + 'inbox_tags': list([ + 9, + ]), + 'storage_path_count': 9, + 'tag_count': 99, + }), + 'status': dict({ + 'database': dict({ + 'error': None, + 'migration_status': dict({ + 'latest_migration': 'paperless_mail.0029_mailrule_pdf_layout', + 'unapplied_migrations': list([ + ]), + }), + 'status': dict({ + '__type': "", + 'repr': "", + }), + 'type': 'sqlite', + 'url': '/config/data/db.sqlite3', + }), + 'install_type': 'docker', + 'pngx_version': '2.15.3', + 'server_os': 'Linux-6.6.74-haos-raspi-aarch64-with-glibc2.36', + 'storage': dict({ + 'available': 25376927744, + 'total': 62101651456, + }), + 'tasks': dict({ + 'celery_error': None, + 'celery_status': dict({ + '__type': "", + 'repr': "", + }), + 'celery_url': 'celery@ca5234a0-paperless-ngx', + 'classifier_error': None, + 'classifier_last_trained': '2025-05-25T15:05:15.824671+00:00', + 'classifier_status': dict({ + '__type': "", + 'repr': "", + }), + 'index_error': None, + 'index_last_modified': '2025-05-25T00:00:27.053090+02:00', + 'index_status': dict({ + '__type': "", + 'repr': "", + }), + 'redis_error': None, + 'redis_status': dict({ + '__type': "", + 'repr': "", + }), + 'redis_url': 'redis://localhost:6379', + 'sanity_check_error': None, + 'sanity_check_last_run': '2025-05-24T22:30:21.005536+00:00', + 'sanity_check_status': dict({ + '__type': "", + 'repr': "", + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/paperless_ngx/snapshots/test_sensor.ambr b/tests/components/paperless_ngx/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..ed023f75726 --- /dev/null +++ b/tests/components/paperless_ngx/snapshots/test_sensor.ambr @@ -0,0 +1,785 @@ +# serializer version: 1 +# name: test_sensor_platform[sensor.paperless_ngx_available_storage-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.paperless_ngx_available_storage', + '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': 'Available storage', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_available', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_storage_available', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_available_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Paperless-ngx Available storage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_available_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.38', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_correspondents-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.paperless_ngx_correspondents', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Correspondents', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'correspondent_count', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_correspondent_count', + 'unit_of_measurement': 'correspondents', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_correspondents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Paperless-ngx Correspondents', + 'state_class': , + 'unit_of_measurement': 'correspondents', + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_correspondents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_document_types-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.paperless_ngx_document_types', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Document types', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'document_type_count', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_document_type_count', + 'unit_of_measurement': 'document types', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_document_types-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Paperless-ngx Document types', + 'state_class': , + 'unit_of_measurement': 'document types', + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_document_types', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_documents_in_inbox-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.paperless_ngx_documents_in_inbox', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Documents in inbox', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'documents_inbox', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_documents_inbox', + 'unit_of_measurement': 'documents', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_documents_in_inbox-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Paperless-ngx Documents in inbox', + 'state_class': , + 'unit_of_measurement': 'documents', + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_documents_in_inbox', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_celery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_celery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status Celery', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'celery_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_celery_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_celery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status Celery', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_celery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_classifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_classifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status classifier', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'classifier_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_classifier_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_classifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status classifier', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_classifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_database-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_database', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status database', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'database_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_database_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_database-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status database', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_database', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status index', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'index_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_index_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status index', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_redis-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_redis', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status Redis', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redis_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_redis_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_redis-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status Redis', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_redis', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_sanity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.paperless_ngx_status_sanity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status sanity', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sanity_check_status', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_sanity_check_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_status_sanity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Paperless-ngx Status sanity', + 'options': list([ + 'ok', + 'error', + 'warning', + ]), + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_status_sanity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_tags-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.paperless_ngx_tags', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tags', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tag_count', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_tag_count', + 'unit_of_measurement': 'tags', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_tags-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Paperless-ngx Tags', + 'state_class': , + 'unit_of_measurement': 'tags', + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_tags', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_total_characters-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.paperless_ngx_total_characters', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total characters', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'characters_count', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_characters_count', + 'unit_of_measurement': 'characters', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_total_characters-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Paperless-ngx Total characters', + 'state_class': , + 'unit_of_measurement': 'characters', + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_total_characters', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99999', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_total_documents-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.paperless_ngx_total_documents', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total documents', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'documents_total', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_documents_total', + 'unit_of_measurement': 'documents', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_total_documents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Paperless-ngx Total documents', + 'state_class': , + 'unit_of_measurement': 'documents', + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_total_documents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '999', + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_total_storage-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.paperless_ngx_total_storage', + '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': 'Total storage', + 'platform': 'paperless_ngx', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_total', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_storage_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_platform[sensor.paperless_ngx_total_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Paperless-ngx Total storage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.paperless_ngx_total_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62.1', + }) +# --- diff --git a/tests/components/paperless_ngx/test_config_flow.py b/tests/components/paperless_ngx/test_config_flow.py new file mode 100644 index 00000000000..b9960818ceb --- /dev/null +++ b/tests/components/paperless_ngx/test_config_flow.py @@ -0,0 +1,260 @@ +"""Tests for the Paperless-ngx config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock + +from pypaperless.exceptions import ( + InitializationError, + PaperlessConnectionError, + PaperlessForbiddenError, + PaperlessInactiveOrDeletedError, + PaperlessInvalidTokenError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.paperless_ngx.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 .const import USER_INPUT_ONE, USER_INPUT_REAUTH, USER_INPUT_TWO + +from tests.common import MockConfigEntry, patch + + +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.paperless_ngx.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_full_config_flow(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow works.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["flow_id"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_ONE, + ) + + config_entry = result["result"] + assert config_entry.title == USER_INPUT_ONE[CONF_URL] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.data == USER_INPUT_ONE + + +async def test_full_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth an integration and finishing flow works.""" + + mock_config_entry.add_to_hass(hass) + + reauth_flow = await mock_config_entry.start_reauth_flow(hass) + assert reauth_flow["type"] is FlowResultType.FORM + assert reauth_flow["step_id"] == "reauth_confirm" + + result_configure = await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], USER_INPUT_REAUTH + ) + + assert result_configure["type"] is FlowResultType.ABORT + assert result_configure["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == USER_INPUT_REAUTH[CONF_API_KEY] + + +async def test_full_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfigure an integration and finishing flow works.""" + + mock_config_entry.add_to_hass(hass) + + reconfigure_flow = await mock_config_entry.start_reconfigure_flow(hass) + assert reconfigure_flow["type"] is FlowResultType.FORM + assert reconfigure_flow["step_id"] == "reconfigure" + + result_configure = await hass.config_entries.flow.async_configure( + reconfigure_flow["flow_id"], + USER_INPUT_TWO, + ) + + assert result_configure["type"] is FlowResultType.ABORT + assert result_configure["reason"] == "reconfigure_successful" + assert mock_config_entry.data == USER_INPUT_TWO + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PaperlessConnectionError(), {CONF_URL: "cannot_connect"}), + (PaperlessInvalidTokenError(), {CONF_API_KEY: "invalid_api_key"}), + (PaperlessInactiveOrDeletedError(), {CONF_API_KEY: "user_inactive_or_deleted"}), + (PaperlessForbiddenError(), {CONF_API_KEY: "forbidden"}), + (InitializationError(), {CONF_URL: "cannot_connect"}), + (Exception("BOOM!"), {"base": "unknown"}), + ], +) +async def test_config_flow_error_handling( + hass: HomeAssistant, + mock_paperless: AsyncMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test user step shows correct error for various client initialization issues.""" + mock_paperless.initialize.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_INPUT_ONE, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == expected_error + + mock_paperless.initialize.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT_ONE, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT_ONE[CONF_URL] + assert result["data"] == USER_INPUT_ONE + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PaperlessConnectionError(), {CONF_URL: "cannot_connect"}), + (PaperlessInvalidTokenError(), {CONF_API_KEY: "invalid_api_key"}), + (PaperlessInactiveOrDeletedError(), {CONF_API_KEY: "user_inactive_or_deleted"}), + (PaperlessForbiddenError(), {CONF_API_KEY: "forbidden"}), + (InitializationError(), {CONF_URL: "cannot_connect"}), + (Exception("BOOM!"), {"base": "unknown"}), + ], +) +async def test_reauth_flow_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_paperless: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test reauth flow with various initialization errors.""" + + mock_config_entry.add_to_hass(hass) + mock_paperless.initialize.side_effect = side_effect + + reauth_flow = await mock_config_entry.start_reauth_flow(hass) + assert reauth_flow["type"] is FlowResultType.FORM + assert reauth_flow["step_id"] == "reauth_confirm" + + result_configure = await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], USER_INPUT_REAUTH + ) + + await hass.async_block_till_done() + + assert result_configure["type"] is FlowResultType.FORM + assert result_configure["errors"] == expected_error + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PaperlessConnectionError(), {CONF_URL: "cannot_connect"}), + (PaperlessInvalidTokenError(), {CONF_API_KEY: "invalid_api_key"}), + (PaperlessInactiveOrDeletedError(), {CONF_API_KEY: "user_inactive_or_deleted"}), + (PaperlessForbiddenError(), {CONF_API_KEY: "forbidden"}), + (InitializationError(), {CONF_URL: "cannot_connect"}), + (Exception("BOOM!"), {"base": "unknown"}), + ], +) +async def test_reconfigure_flow_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_paperless: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test reconfigure flow with various initialization errors.""" + + mock_config_entry.add_to_hass(hass) + mock_paperless.initialize.side_effect = side_effect + + reauth_flow = await mock_config_entry.start_reconfigure_flow(hass) + assert reauth_flow["type"] is FlowResultType.FORM + assert reauth_flow["step_id"] == "reconfigure" + + result_configure = await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], + USER_INPUT_TWO, + ) + + await hass.async_block_till_done() + + assert result_configure["type"] is FlowResultType.FORM + assert result_configure["errors"] == expected_error + + +async def test_config_already_exists( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we only allow a single config flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=USER_INPUT_ONE, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_config_already_exists_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test we only allow a single config if reconfiguring an entry.""" + mock_config_entry.add_to_hass(hass) + mock_config_entry_two = MockConfigEntry( + entry_id="J87G00V55WEVTJ0CJHM0GADBH5", + title="Paperless-ngx - Two", + domain=DOMAIN, + data=USER_INPUT_TWO, + ) + mock_config_entry_two.add_to_hass(hass) + + reconfigure_flow = await mock_config_entry_two.start_reconfigure_flow(hass) + assert reconfigure_flow["type"] is FlowResultType.FORM + assert reconfigure_flow["step_id"] == "reconfigure" + + result_configure = await hass.config_entries.flow.async_configure( + reconfigure_flow["flow_id"], + USER_INPUT_ONE, + ) + + assert result_configure["type"] is FlowResultType.ABORT + assert result_configure["reason"] == "already_configured" diff --git a/tests/components/paperless_ngx/test_diagnostics.py b/tests/components/paperless_ngx/test_diagnostics.py new file mode 100644 index 00000000000..03d34c37fc6 --- /dev/null +++ b/tests/components/paperless_ngx/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for Paperless-ngx sensor platform.""" + +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_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_paperless: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + 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/paperless_ngx/test_init.py b/tests/components/paperless_ngx/test_init.py new file mode 100644 index 00000000000..fd459213ea0 --- /dev/null +++ b/tests/components/paperless_ngx/test_init.py @@ -0,0 +1,83 @@ +"""Test the Paperless-ngx integration initialization.""" + +from unittest.mock import AsyncMock + +from pypaperless.exceptions import ( + InitializationError, + PaperlessConnectionError, + PaperlessForbiddenError, + PaperlessInactiveOrDeletedError, + PaperlessInvalidTokenError, +) +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 + + +async def test_load_config_status_forbidden( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_paperless: AsyncMock, +) -> None: + """Test loading and unloading the integration.""" + mock_paperless.status.side_effect = PaperlessForbiddenError + + 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( + ("side_effect", "expected_state", "expected_error_key"), + [ + (PaperlessConnectionError(), ConfigEntryState.SETUP_RETRY, "cannot_connect"), + (PaperlessInvalidTokenError(), ConfigEntryState.SETUP_ERROR, "invalid_api_key"), + ( + PaperlessInactiveOrDeletedError(), + ConfigEntryState.SETUP_ERROR, + "user_inactive_or_deleted", + ), + (PaperlessForbiddenError(), ConfigEntryState.SETUP_ERROR, "forbidden"), + (InitializationError(), ConfigEntryState.SETUP_ERROR, "cannot_connect"), + ], +) +async def test_setup_config_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_paperless: AsyncMock, + side_effect: Exception, + expected_state: ConfigEntryState, + expected_error_key: str, +) -> None: + """Test all initialization error paths during setup.""" + mock_paperless.initialize.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state == expected_state + assert mock_config_entry.error_reason_translation_key == expected_error_key diff --git a/tests/components/paperless_ngx/test_sensor.py b/tests/components/paperless_ngx/test_sensor.py new file mode 100644 index 00000000000..d2233a64ee2 --- /dev/null +++ b/tests/components/paperless_ngx/test_sensor.py @@ -0,0 +1,113 @@ +"""Tests for Paperless-ngx sensor platform.""" + +from freezegun.api import FrozenDateTimeFactory +from pypaperless.exceptions import ( + PaperlessConnectionError, + PaperlessForbiddenError, + PaperlessInactiveOrDeletedError, + PaperlessInvalidTokenError, +) +from pypaperless.models import Statistic +import pytest + +from homeassistant.components.paperless_ngx.coordinator import ( + UPDATE_INTERVAL_STATISTICS, +) +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + AsyncMock, + MockConfigEntry, + SnapshotAssertion, + async_fire_time_changed, + patch, + snapshot_platform, +) + + +async def test_sensor_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test paperless_ngx update sensors.""" + with patch("homeassistant.components.paperless_ngx.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_statistic_sensor_state( + hass: HomeAssistant, + mock_paperless: AsyncMock, + freezer: FrozenDateTimeFactory, + mock_statistic_data_update, +) -> None: + """Ensure sensor entities are added automatically.""" + # initialize with 999 documents + state = hass.states.get("sensor.paperless_ngx_total_documents") + assert state.state == "999" + + # update to 420 documents + mock_paperless.statistics = AsyncMock( + return_value=Statistic.create_with_data( + mock_paperless, data=mock_statistic_data_update, fetched=True + ) + ) + + freezer.tick(UPDATE_INTERVAL_STATISTICS) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.paperless_ngx_total_documents") + assert state.state == "420" + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("error_cls", "assert_state"), + [ + (PaperlessForbiddenError, "420"), + (PaperlessConnectionError, "420"), + (PaperlessInactiveOrDeletedError, STATE_UNAVAILABLE), + (PaperlessInvalidTokenError, STATE_UNAVAILABLE), + ], +) +async def test__statistic_sensor_state_on_error( + hass: HomeAssistant, + mock_paperless: AsyncMock, + freezer: FrozenDateTimeFactory, + mock_statistic_data_update, + error_cls, + assert_state, +) -> None: + """Ensure sensor entities are added automatically.""" + # simulate error + mock_paperless.statistics.side_effect = error_cls + + freezer.tick(UPDATE_INTERVAL_STATISTICS) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.paperless_ngx_total_documents") + assert state.state == STATE_UNAVAILABLE + + # recover from not auth errors + mock_paperless.statistics = AsyncMock( + return_value=Statistic.create_with_data( + mock_paperless, data=mock_statistic_data_update, fetched=True + ) + ) + + freezer.tick(UPDATE_INTERVAL_STATISTICS) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.paperless_ngx_total_documents") + assert state.state == assert_state diff --git a/tests/components/peblar/snapshots/test_binary_sensor.ambr b/tests/components/peblar/snapshots/test_binary_sensor.ambr index 9ad9c877ed2..ed39bbf171b 100644 --- a/tests/components/peblar/snapshots/test_binary_sensor.ambr +++ b/tests/components/peblar/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Active errors', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_error_codes', 'unique_id': '23-45-A4O-MOF_active_error_codes', @@ -75,6 +76,7 @@ 'original_name': 'Active warnings', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_warning_codes', 'unique_id': '23-45-A4O-MOF_active_warning_codes', diff --git a/tests/components/peblar/snapshots/test_button.ambr b/tests/components/peblar/snapshots/test_button.ambr index 6d31da0ae52..b46dc0b0eca 100644 --- a/tests/components/peblar/snapshots/test_button.ambr +++ b/tests/components/peblar/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Identify', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '23-45-A4O-MOF_identify', @@ -75,6 +76,7 @@ 'original_name': 'Restart', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '23-45-A4O-MOF_reboot', diff --git a/tests/components/peblar/snapshots/test_number.ambr b/tests/components/peblar/snapshots/test_number.ambr index d8e9c756c50..f7fd499d112 100644 --- a/tests/components/peblar/snapshots/test_number.ambr +++ b/tests/components/peblar/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Charge limit', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_current_limit', 'unique_id': '23-45-A4O-MOF_charge_current_limit', diff --git a/tests/components/peblar/snapshots/test_select.ambr b/tests/components/peblar/snapshots/test_select.ambr index 3a600653a84..95146997039 100644 --- a/tests/components/peblar/snapshots/test_select.ambr +++ b/tests/components/peblar/snapshots/test_select.ambr @@ -35,6 +35,7 @@ 'original_name': 'Smart charging', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smart_charging', 'unique_id': '23-45-A4O-MOF_smart_charging', diff --git a/tests/components/peblar/snapshots/test_sensor.ambr b/tests/components/peblar/snapshots/test_sensor.ambr index 5a1d1663ba2..2963693d77d 100644 --- a/tests/components/peblar/snapshots/test_sensor.ambr +++ b/tests/components/peblar/snapshots/test_sensor.ambr @@ -35,6 +35,7 @@ 'original_name': 'Current', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '23-45-A4O-MOF_current_total', @@ -93,6 +94,7 @@ 'original_name': 'Current phase 1', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_phase_1', 'unique_id': '23-45-A4O-MOF_current_phase_1', @@ -151,6 +153,7 @@ 'original_name': 'Current phase 2', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_phase_2', 'unique_id': '23-45-A4O-MOF_current_phase_2', @@ -209,6 +212,7 @@ 'original_name': 'Current phase 3', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_phase_3', 'unique_id': '23-45-A4O-MOF_current_phase_3', @@ -267,6 +271,7 @@ 'original_name': 'Lifetime energy', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_total', 'unique_id': '23-45-A4O-MOF_energy_total', @@ -337,6 +342,7 @@ 'original_name': 'Limit source', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_current_limit_source', 'unique_id': '23-45-A4O-MOF_charge_current_limit_source', @@ -400,12 +406,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '23-45-A4O-MOF_power_total', @@ -452,12 +462,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power phase 1', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_phase_1', 'unique_id': '23-45-A4O-MOF_power_phase_1', @@ -504,12 +518,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power phase 2', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_phase_2', 'unique_id': '23-45-A4O-MOF_power_phase_2', @@ -556,12 +574,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power phase 3', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_phase_3', 'unique_id': '23-45-A4O-MOF_power_phase_3', @@ -620,6 +642,7 @@ 'original_name': 'Session energy', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_session', 'unique_id': '23-45-A4O-MOF_energy_session', @@ -680,6 +703,7 @@ 'original_name': 'State', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cp_state', 'unique_id': '23-45-A4O-MOF_cp_state', @@ -737,6 +761,7 @@ 'original_name': 'Uptime', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uptime', 'unique_id': '23-45-A4O-MOF_uptime', @@ -781,12 +806,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_phase_1', 'unique_id': '23-45-A4O-MOF_voltage_phase_1', @@ -833,12 +862,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_phase_2', 'unique_id': '23-45-A4O-MOF_voltage_phase_2', @@ -885,12 +918,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_phase_3', 'unique_id': '23-45-A4O-MOF_voltage_phase_3', diff --git a/tests/components/peblar/snapshots/test_switch.ambr b/tests/components/peblar/snapshots/test_switch.ambr index 46051974339..f3b9775e339 100644 --- a/tests/components/peblar/snapshots/test_switch.ambr +++ b/tests/components/peblar/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge', 'unique_id': '23-45-A4O-MOF_charge', @@ -74,6 +75,7 @@ 'original_name': 'Force single phase', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'force_single_phase', 'unique_id': '23-45-A4O-MOF_force_single_phase', diff --git a/tests/components/peblar/snapshots/test_update.ambr b/tests/components/peblar/snapshots/test_update.ambr index 0a6b2bf069f..48a92dcad49 100644 --- a/tests/components/peblar/snapshots/test_update.ambr +++ b/tests/components/peblar/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': 'Customization', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'customization', 'unique_id': '23-45-A4O-MOF_customization', @@ -86,6 +87,7 @@ 'original_name': 'Firmware', 'platform': 'peblar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '23-45-A4O-MOF_firmware', diff --git a/tests/components/pegel_online/test_diagnostics.py b/tests/components/pegel_online/test_diagnostics.py index 220f244b751..a5b08d4bae2 100644 --- a/tests/components/pegel_online/test_diagnostics.py +++ b/tests/components/pegel_online/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.pegel_online.const import CONF_STATION, DOMAIN diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py index 75932dd036c..0991d6bd814 100644 --- a/tests/components/pglab/test_sensor.py +++ b/tests/components/pglab/test_sensor.py @@ -4,7 +4,7 @@ import json from freezegun import freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/philips_js/test_diagnostics.py b/tests/components/philips_js/test_diagnostics.py index d61546e52c3..0d8909c86be 100644 --- a/tests/components/philips_js/test_diagnostics.py +++ b/tests/components/philips_js/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from haphilipsjs.typing import ChannelListType, ContextType, FavoriteListType -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr index bb28432841f..c5a97fa5d22 100644 --- a/tests/components/ping/snapshots/test_binary_sensor.ambr +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'ping', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unit_of_measurement': None, diff --git a/tests/components/ping/snapshots/test_sensor.ambr b/tests/components/ping/snapshots/test_sensor.ambr index 6b86c327863..f09bfe61065 100644 --- a/tests/components/ping/snapshots/test_sensor.ambr +++ b/tests/components/ping/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Round-trip time average', 'platform': 'ping', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'round_trip_time_avg', 'unit_of_measurement': , @@ -74,12 +78,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Round-trip time maximum', 'platform': 'ping', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'round_trip_time_max', 'unit_of_measurement': , @@ -131,12 +139,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Round-trip time minimum', 'platform': 'ping', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'round_trip_time_min', 'unit_of_measurement': , diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index 660b5ca31f1..93742ca9005 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from icmplib import Host import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.ping.const import CONF_IMPORTED_BY, DOMAIN diff --git a/tests/components/ping/test_sensor.py b/tests/components/ping/test_sensor.py index 5c4833aaf06..bdc8b7d28e4 100644 --- a/tests/components/ping/test_sensor.py +++ b/tests/components/ping/test_sensor.py @@ -1,7 +1,7 @@ """Test sensor platform of Ping.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/plaato/snapshots/test_binary_sensor.ambr b/tests/components/plaato/snapshots/test_binary_sensor.ambr index 76c0a299c5e..2eb77505c11 100644 --- a/tests/components/plaato/snapshots/test_binary_sensor.ambr +++ b/tests/components/plaato/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Leaking', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.LEAK_DETECTION', @@ -78,6 +79,7 @@ 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Pouring', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.POURING', diff --git a/tests/components/plaato/snapshots/test_sensor.ambr b/tests/components/plaato/snapshots/test_sensor.ambr index 24ba62e28ca..a64fe5f1b71 100644 --- a/tests/components/plaato/snapshots/test_sensor.ambr +++ b/tests/components/plaato/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Alcohol By Volume', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.ABV', @@ -75,6 +76,7 @@ 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Batch Volume', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.BATCH_VOLUME', @@ -122,6 +124,7 @@ 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Bubbles', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.BUBBLES', @@ -170,6 +173,7 @@ 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Bubbles Per Minute', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.BPM', @@ -218,6 +222,7 @@ 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Co2 Volume', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.CO2_VOLUME', @@ -265,6 +270,7 @@ 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Original Gravity', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.OG', @@ -313,6 +319,7 @@ 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Specific Gravity', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.SG', @@ -361,6 +368,7 @@ 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Temperature', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.TEMPERATURE', @@ -408,6 +416,7 @@ 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Beer Left', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.BEER_LEFT', @@ -458,6 +467,7 @@ 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Last Pour Amount', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.LAST_POUR', @@ -509,6 +519,7 @@ 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Percent Beer Left', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.PERCENT_BEER_LEFT', @@ -554,12 +565,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Temperature', 'platform': 'plaato', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'valid_token_Pins.TEMPERATURE', diff --git a/tests/components/plaato/test_binary_sensor.py b/tests/components/plaato/test_binary_sensor.py index 73d378dd531..5542c79e8ea 100644 --- a/tests/components/plaato/test_binary_sensor.py +++ b/tests/components/plaato/test_binary_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyplaato.models.device import PlaatoDeviceType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/plaato/test_sensor.py b/tests/components/plaato/test_sensor.py index e4574634c4b..63e9255faa0 100644 --- a/tests/components/plaato/test_sensor.py +++ b/tests/components/plaato/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyplaato.models.device import PlaatoDeviceType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index a2b0521d6e1..dbfd810d4dc 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/poolsense/snapshots/test_binary_sensor.ambr b/tests/components/poolsense/snapshots/test_binary_sensor.ambr index b3d99b95308..f0e008d4f70 100644 --- a/tests/components/poolsense/snapshots/test_binary_sensor.ambr +++ b/tests/components/poolsense/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Chlorine status', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'chlorine_status', 'unique_id': 'test@test.com-Chlorine Status', @@ -76,6 +77,7 @@ 'original_name': 'pH status', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ph_status', 'unique_id': 'test@test.com-pH Status', diff --git a/tests/components/poolsense/snapshots/test_sensor.ambr b/tests/components/poolsense/snapshots/test_sensor.ambr index c0066ba9396..07ea998d902 100644 --- a/tests/components/poolsense/snapshots/test_sensor.ambr +++ b/tests/components/poolsense/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test@test.com-Battery', @@ -77,6 +78,7 @@ 'original_name': 'Chlorine', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'chlorine', 'unique_id': 'test@test.com-Chlorine', @@ -126,6 +128,7 @@ 'original_name': 'Chlorine high', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'chlorine_high', 'unique_id': 'test@test.com-Chlorine High', @@ -175,6 +178,7 @@ 'original_name': 'Chlorine low', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'chlorine_low', 'unique_id': 'test@test.com-Chlorine Low', @@ -224,6 +228,7 @@ 'original_name': 'Last seen', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_seen', 'unique_id': 'test@test.com-Last Seen', @@ -273,6 +278,7 @@ 'original_name': 'pH', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test@test.com-pH', @@ -322,6 +328,7 @@ 'original_name': 'pH high', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ph_high', 'unique_id': 'test@test.com-pH High', @@ -370,6 +377,7 @@ 'original_name': 'pH low', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ph_low', 'unique_id': 'test@test.com-pH Low', @@ -412,12 +420,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'poolsense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_temp', 'unique_id': 'test@test.com-Water Temp', diff --git a/tests/components/poolsense/test_binary_sensor.py b/tests/components/poolsense/test_binary_sensor.py index 4d10413c124..debf0faa52a 100644 --- a/tests/components/poolsense/test_binary_sensor.py +++ b/tests/components/poolsense/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/poolsense/test_sensor.py b/tests/components/poolsense/test_sensor.py index 7f088eee6a3..bac5dd8c701 100644 --- a/tests/components/poolsense/test_sensor.py +++ b/tests/components/poolsense/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/powerfox/snapshots/test_sensor.ambr b/tests/components/powerfox/snapshots/test_sensor.ambr index bae306ccabc..54976dfaa79 100644 --- a/tests/components/powerfox/snapshots/test_sensor.ambr +++ b/tests/components/powerfox/snapshots/test_sensor.ambr @@ -21,12 +21,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Delta energy', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heat_delta_energy', 'unique_id': '9x9x1f12xx5x_heat_delta_energy', @@ -79,6 +83,7 @@ 'original_name': 'Delta volume', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heat_delta_volume', 'unique_id': '9x9x1f12xx5x_heat_delta_volume', @@ -124,12 +129,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heat_total_energy', 'unique_id': '9x9x1f12xx5x_heat_total_energy', @@ -176,12 +185,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total volume', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heat_total_volume', 'unique_id': '9x9x1f12xx5x_heat_total_volume', @@ -228,12 +241,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy return', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_return', 'unique_id': '9x9x1f12xx3x_energy_return', @@ -280,12 +297,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy usage', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_usage', 'unique_id': '9x9x1f12xx3x_energy_usage', @@ -332,12 +353,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy usage high tariff', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_usage_high_tariff', 'unique_id': '9x9x1f12xx3x_energy_usage_high_tariff', @@ -384,12 +409,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy usage low tariff', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_usage_low_tariff', 'unique_id': '9x9x1f12xx3x_energy_usage_low_tariff', @@ -436,12 +465,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '9x9x1f12xx3x_power', @@ -488,12 +521,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Cold water', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cold_water', 'unique_id': '9x9x1f12xx4x_cold_water', @@ -540,12 +577,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Warm water', 'platform': 'powerfox', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'warm_water', 'unique_id': '9x9x1f12xx4x_warm_water', diff --git a/tests/components/powerfox/test_diagnostics.py b/tests/components/powerfox/test_diagnostics.py index 7dc2c3c7263..220c809a5f9 100644 --- a/tests/components/powerfox/test_diagnostics.py +++ b/tests/components/powerfox/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/powerfox/test_sensor.py b/tests/components/powerfox/test_sensor.py index 547d8de202c..2dfc1227d77 100644 --- a/tests/components/powerfox/test_sensor.py +++ b/tests/components/powerfox/test_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from powerfox import PowerfoxConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/probe_plus/__init__.py b/tests/components/probe_plus/__init__.py new file mode 100644 index 00000000000..22f0d7dd1c3 --- /dev/null +++ b/tests/components/probe_plus/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Probe Plus 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 Probe Plus 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/probe_plus/conftest.py b/tests/components/probe_plus/conftest.py new file mode 100644 index 00000000000..ddbad5c46b1 --- /dev/null +++ b/tests/components/probe_plus/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the Probe Plus tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pyprobeplus.parser import ParserBase, ProbePlusData +import pytest + +from homeassistant.components.probe_plus.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.probe_plus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="FM210 aa:bb:cc:dd:ee:ff", + domain=DOMAIN, + version=1, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + + +@pytest.fixture +def mock_probe_plus() -> MagicMock: + """Mock the Probe Plus device.""" + with patch( + "homeassistant.components.probe_plus.coordinator.ProbePlusDevice", + autospec=True, + ) as mock_device: + device = mock_device.return_value + device.connected = True + device.name = "FM210 aa:bb:cc:dd:ee:ff" + mock_state = ParserBase() + mock_state.state = ProbePlusData( + relay_battery=50, + probe_battery=50, + probe_temperature=25.0, + probe_rssi=200, + probe_voltage=3.7, + relay_status=1, + relay_voltage=9.0, + ) + device._device_state = mock_state + yield device diff --git a/tests/components/probe_plus/test_config_flow.py b/tests/components/probe_plus/test_config_flow.py new file mode 100644 index 00000000000..1d248144311 --- /dev/null +++ b/tests/components/probe_plus/test_config_flow.py @@ -0,0 +1,133 @@ +"""Test the config flow for the Probe Plus.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.probe_plus.const import DOMAIN +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +service_info = BluetoothServiceInfo( + name="FM210", + address="aa:bb:cc:dd:ee:ff", + rssi=-63, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", +) + + +@pytest.fixture +def mock_discovered_service_info() -> Generator[AsyncMock]: + """Override getting Bluetooth service info.""" + with patch( + "homeassistant.components.probe_plus.config_flow.async_discovered_service_info", + return_value=[service_info], + ) as mock_discovered_service_info: + yield mock_discovered_service_info + + +async def test_user_config_flow_creates_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test the user configuration flow successfully creates a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + assert result["title"] == "FM210 aa:bb:cc:dd:ee:ff" + assert result["data"] == {CONF_ADDRESS: "aa:bb:cc:dd:ee:ff"} + + +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_discovered_service_info: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test that the user flow aborts when the entry is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + # this aborts with no devices found as the config flow + # already checks for existing config entries when validating the discovered devices + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_bluetooth_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test we can discover a device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "FM210 aa:bb:cc:dd:ee:ff" + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + assert result["data"] == { + CONF_ADDRESS: service_info.address, + } + + +async def test_already_configured_bluetooth_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure configure device is not discovered again.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_no_bluetooth_devices( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test flow aborts on unsupported device.""" + mock_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" diff --git a/tests/components/pterodactyl/__init__.py b/tests/components/pterodactyl/__init__.py index a5b28d67ae3..0142399ec42 100644 --- a/tests/components/pterodactyl/__init__.py +++ b/tests/components/pterodactyl/__init__.py @@ -1 +1,16 @@ """Tests for the Pterodactyl integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up Pterodactyl mock config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/pterodactyl/conftest.py b/tests/components/pterodactyl/conftest.py index 62326e79207..c395410b6ae 100644 --- a/tests/components/pterodactyl/conftest.py +++ b/tests/components/pterodactyl/conftest.py @@ -9,108 +9,9 @@ import pytest from homeassistant.components.pterodactyl.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL -from tests.common import MockConfigEntry +from .const import TEST_API_KEY, TEST_URL -TEST_URL = "https://192.168.0.1:8080/" -TEST_API_KEY = "TestClientApiKey" -TEST_USER_INPUT = { - CONF_URL: TEST_URL, - CONF_API_KEY: TEST_API_KEY, -} -TEST_SERVER_LIST_DATA = { - "meta": {"pagination": {"total": 2, "count": 2, "per_page": 50, "current_page": 1}}, - "data": [ - { - "object": "server", - "attributes": { - "server_owner": True, - "identifier": "1", - "internal_id": 1, - "uuid": "1-1-1-1-1", - "name": "Test Server 1", - "node": "default_node", - "description": "Description of Test Server 1", - "limits": { - "memory": 2048, - "swap": 1024, - "disk": 10240, - "io": 500, - "cpu": 100, - "threads": None, - "oom_disabled": True, - }, - "invocation": "java -jar test_server1.jar", - "docker_image": "test_docker_image_1", - "egg_features": ["java_version"], - }, - }, - { - "object": "server", - "attributes": { - "server_owner": True, - "identifier": "2", - "internal_id": 2, - "uuid": "2-2-2-2-2", - "name": "Test Server 2", - "node": "default_node", - "description": "Description of Test Server 2", - "limits": { - "memory": 2048, - "swap": 1024, - "disk": 10240, - "io": 500, - "cpu": 100, - "threads": None, - "oom_disabled": True, - }, - "invocation": "java -jar test_server_2.jar", - "docker_image": "test_docker_image2", - "egg_features": ["java_version"], - }, - }, - ], -} -TEST_SERVER = { - "server_owner": True, - "identifier": "1", - "internal_id": 1, - "uuid": "1-1-1-1-1", - "name": "Test Server 1", - "node": "default_node", - "is_node_under_maintenance": False, - "sftp_details": {"ip": "192.168.0.1", "port": 2022}, - "description": "", - "limits": { - "memory": 2048, - "swap": 1024, - "disk": 10240, - "io": 500, - "cpu": 100, - "threads": None, - "oom_disabled": True, - }, - "invocation": "java -jar test.jar", - "docker_image": "test_docker_image", - "egg_features": ["eula", "java_version", "pid_limit"], - "feature_limits": {"databases": 0, "allocations": 0, "backups": 3}, - "status": None, - "is_suspended": False, - "is_installing": False, - "is_transferring": False, - "relationships": {"allocations": {...}, "variables": {...}}, -} -TEST_SERVER_UTILIZATION = { - "current_state": "running", - "is_suspended": False, - "resources": { - "memory_bytes": 1111, - "cpu_absolute": 22, - "disk_bytes": 3333, - "network_rx_bytes": 44, - "network_tx_bytes": 55, - "uptime": 6666, - }, -} +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -139,17 +40,25 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_pterodactyl(): +def mock_pterodactyl() -> Generator[AsyncMock]: """Mock the Pterodactyl API.""" with patch( "homeassistant.components.pterodactyl.api.PterodactylClient", autospec=True ) as mock: + server_list_data = load_json_object_fixture("server_list_data.json", DOMAIN) + server_1_data = load_json_object_fixture("server_1_data.json", DOMAIN) + server_2_data = load_json_object_fixture("server_2_data.json", DOMAIN) + utilization_data = load_json_object_fixture("utilization_data.json", DOMAIN) + mock.return_value.client.servers.list_servers.return_value = PaginatedResponse( - mock.return_value, "client", TEST_SERVER_LIST_DATA + mock.return_value, "client", server_list_data ) - mock.return_value.client.servers.get_server.return_value = TEST_SERVER + mock.return_value.client.servers.get_server.side_effect = [ + server_1_data, + server_2_data, + ] mock.return_value.client.servers.get_server_utilization.return_value = ( - TEST_SERVER_UTILIZATION + utilization_data ) yield mock.return_value diff --git a/tests/components/pterodactyl/const.py b/tests/components/pterodactyl/const.py new file mode 100644 index 00000000000..f6684a82fc5 --- /dev/null +++ b/tests/components/pterodactyl/const.py @@ -0,0 +1,12 @@ +"""Constants for Pterodactyl tests.""" + +from homeassistant.const import CONF_API_KEY, CONF_URL + +TEST_URL = "https://192.168.0.1:8080/" + +TEST_API_KEY = "TestClientApiKey" + +TEST_USER_INPUT = { + CONF_URL: TEST_URL, + CONF_API_KEY: TEST_API_KEY, +} diff --git a/tests/components/pterodactyl/fixtures/server_1_data.json b/tests/components/pterodactyl/fixtures/server_1_data.json new file mode 100644 index 00000000000..c780d55b318 --- /dev/null +++ b/tests/components/pterodactyl/fixtures/server_1_data.json @@ -0,0 +1,39 @@ +{ + "server_owner": true, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "is_node_under_maintenance": false, + "sftp_details": { + "ip": "192.168.0.1", + "port": 2022 + }, + "description": "", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": null, + "oom_disabled": true + }, + "invocation": "java -jar test1.jar", + "docker_image": "test_docker_image1", + "egg_features": ["eula", "java_version", "pid_limit"], + "feature_limits": { + "databases": 0, + "allocations": 0, + "backups": 3 + }, + "status": null, + "is_suspended": false, + "is_installing": false, + "is_transferring": false, + "relationships": { + "allocations": {}, + "variables": {} + } +} diff --git a/tests/components/pterodactyl/fixtures/server_2_data.json b/tests/components/pterodactyl/fixtures/server_2_data.json new file mode 100644 index 00000000000..b240ff62ced --- /dev/null +++ b/tests/components/pterodactyl/fixtures/server_2_data.json @@ -0,0 +1,39 @@ +{ + "server_owner": true, + "identifier": "2", + "internal_id": 2, + "uuid": "2-2-2-2-2", + "name": "Test Server 2", + "node": "default_node", + "is_node_under_maintenance": false, + "sftp_details": { + "ip": "192.168.0.2", + "port": 2022 + }, + "description": "", + "limits": { + "memory": 4096, + "swap": 2048, + "disk": 20480, + "io": 1000, + "cpu": 200, + "threads": null, + "oom_disabled": true + }, + "invocation": "java -jar test2.jar", + "docker_image": "test_docker_image2", + "egg_features": ["eula", "java_version", "pid_limit"], + "feature_limits": { + "databases": 1, + "allocations": 1, + "backups": 5 + }, + "status": null, + "is_suspended": false, + "is_installing": false, + "is_transferring": false, + "relationships": { + "allocations": {}, + "variables": {} + } +} diff --git a/tests/components/pterodactyl/fixtures/server_list_data.json b/tests/components/pterodactyl/fixtures/server_list_data.json new file mode 100644 index 00000000000..d8796ad533e --- /dev/null +++ b/tests/components/pterodactyl/fixtures/server_list_data.json @@ -0,0 +1,60 @@ +{ + "meta": { + "pagination": { + "total": 2, + "count": 2, + "per_page": 50, + "current_page": 1 + } + }, + "data": [ + { + "object": "server", + "attributes": { + "server_owner": true, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "description": "Description of Test Server 1", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": null, + "oom_disabled": true + }, + "invocation": "java -jar test1.jar", + "docker_image": "test_docker_image_1", + "egg_features": ["java_version"] + } + }, + { + "object": "server", + "attributes": { + "server_owner": true, + "identifier": "2", + "internal_id": 2, + "uuid": "2-2-2-2-2", + "name": "Test Server 2", + "node": "default_node", + "description": "Description of Test Server 2", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": null, + "oom_disabled": true + }, + "invocation": "java -jar test2.jar", + "docker_image": "test_docker_image2", + "egg_features": ["java_version"] + } + } + ] +} diff --git a/tests/components/pterodactyl/fixtures/utilization_data.json b/tests/components/pterodactyl/fixtures/utilization_data.json new file mode 100644 index 00000000000..6b71cb44635 --- /dev/null +++ b/tests/components/pterodactyl/fixtures/utilization_data.json @@ -0,0 +1,12 @@ +{ + "current_state": "running", + "is_suspended": false, + "resources": { + "memory_bytes": 1111, + "cpu_absolute": 22, + "disk_bytes": 3333, + "network_rx_bytes": 44, + "network_tx_bytes": 55, + "uptime": 6666 + } +} diff --git a/tests/components/pterodactyl/snapshots/test_binary_sensor.ambr b/tests/components/pterodactyl/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..f9f6cbfc44f --- /dev/null +++ b/tests/components/pterodactyl/snapshots/test_binary_sensor.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.test_server_1_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.test_server_1_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'pterodactyl', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '1-1-1-1-1_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_server_1_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Server 1 Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_server_1_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_server_2_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.test_server_2_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'pterodactyl', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '2-2-2-2-2_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_server_2_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Server 2 Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_server_2_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/pterodactyl/test_binary_sensor.py b/tests/components/pterodactyl/test_binary_sensor.py new file mode 100644 index 00000000000..4bacd30e011 --- /dev/null +++ b/tests/components/pterodactyl/test_binary_sensor.py @@ -0,0 +1,89 @@ +"""Tests for the binary sensor platform of the Pterodactyl integration.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from requests.exceptions import ConnectionError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, 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 + + +@pytest.mark.usefixtures("mock_pterodactyl") +async def test_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor.""" + with patch( + "homeassistant.components.pterodactyl._PLATFORMS", [Platform.BINARY_SENSOR] + ): + mock_config_entry = await setup_integration(hass, mock_config_entry) + + assert len(hass.states.async_all(Platform.BINARY_SENSOR)) == 2 + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("mock_pterodactyl") +async def test_binary_sensor_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor update.""" + await setup_integration(hass, mock_config_entry) + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(hass.states.async_all(Platform.BINARY_SENSOR)) == 2 + assert ( + hass.states.get(f"{Platform.BINARY_SENSOR}.test_server_1_status").state + == STATE_ON + ) + assert ( + hass.states.get(f"{Platform.BINARY_SENSOR}.test_server_2_status").state + == STATE_ON + ) + + +async def test_binary_sensor_update_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pterodactyl: Generator[AsyncMock], + freezer: FrozenDateTimeFactory, +) -> None: + """Test failed binary sensor update.""" + await setup_integration(hass, mock_config_entry) + + mock_pterodactyl.client.servers.get_server.side_effect = ConnectionError( + "Simulated connection error" + ) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(hass.states.async_all(Platform.BINARY_SENSOR)) == 2 + assert ( + hass.states.get(f"{Platform.BINARY_SENSOR}.test_server_1_status").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get(f"{Platform.BINARY_SENSOR}.test_server_2_status").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 88247085083..8837fbe753b 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Pterodactyl config flow.""" -from pydactyl import PterodactylClient +from collections.abc import Generator +from unittest.mock import AsyncMock + from pydactyl.exceptions import BadRequestError, PterodactylApiError import pytest from requests.exceptions import HTTPError @@ -12,7 +14,7 @@ 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_API_KEY, TEST_URL, TEST_USER_INPUT +from .const import TEST_API_KEY, TEST_URL, TEST_USER_INPUT from tests.common import MockConfigEntry @@ -59,7 +61,7 @@ async def test_recovery_after_error( hass: HomeAssistant, exception_type: Exception, expected_error: str, - mock_pterodactyl: PterodactylClient, + mock_pterodactyl: Generator[AsyncMock], ) -> None: """Test recovery after an error.""" result = await hass.config_entries.flow.async_init( @@ -143,7 +145,7 @@ async def test_reauth_recovery_after_error( exception_type: Exception, expected_error: str, mock_config_entry: MockConfigEntry, - mock_pterodactyl: PterodactylClient, + mock_pterodactyl: Generator[AsyncMock], ) -> None: """Test recovery after an error during re-authentication.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/pushover/test_config_flow.py b/tests/components/pushover/test_config_flow.py index 58485bfb427..a3c9ac3ccbb 100644 --- a/tests/components/pushover/test_config_flow.py +++ b/tests/components/pushover/test_config_flow.py @@ -217,7 +217,7 @@ async def test_reauth_with_existing_config(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_API_KEY: "MYAPIKEY2", + CONF_API_KEY: MOCK_CONFIG[CONF_API_KEY], }, ) diff --git a/tests/components/pyload/snapshots/test_button.ambr b/tests/components/pyload/snapshots/test_button.ambr index 57a0358da42..4cc5bd42e6c 100644 --- a/tests/components/pyload/snapshots/test_button.ambr +++ b/tests/components/pyload/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Abort all running downloads', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_abort_downloads', @@ -74,6 +75,7 @@ 'original_name': 'Delete finished files/packages', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_delete_finished', @@ -121,6 +123,7 @@ 'original_name': 'Restart all failed files', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_restart_failed', @@ -168,6 +171,7 @@ 'original_name': 'Restart pyload core', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_restart', diff --git a/tests/components/pyload/snapshots/test_sensor.ambr b/tests/components/pyload/snapshots/test_sensor.ambr index d9948f4273a..ce2b822a6aa 100644 --- a/tests/components/pyload/snapshots/test_sensor.ambr +++ b/tests/components/pyload/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Active downloads', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_active', @@ -80,6 +81,7 @@ 'original_name': 'Downloads in queue', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_queue', @@ -135,6 +137,7 @@ 'original_name': 'Free space', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_free_space', @@ -190,6 +193,7 @@ 'original_name': 'Speed', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_speed', @@ -241,6 +245,7 @@ 'original_name': 'Total downloads', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_total', @@ -292,6 +297,7 @@ 'original_name': 'Active downloads', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_active', @@ -343,6 +349,7 @@ 'original_name': 'Downloads in queue', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_queue', @@ -398,6 +405,7 @@ 'original_name': 'Free space', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_free_space', @@ -453,6 +461,7 @@ 'original_name': 'Speed', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_speed', @@ -504,6 +513,7 @@ 'original_name': 'Total downloads', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_total', @@ -555,6 +565,7 @@ 'original_name': 'Active downloads', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_active', @@ -606,6 +617,7 @@ 'original_name': 'Downloads in queue', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_queue', @@ -661,6 +673,7 @@ 'original_name': 'Free space', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_free_space', @@ -716,6 +729,7 @@ 'original_name': 'Speed', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_speed', @@ -767,6 +781,7 @@ 'original_name': 'Total downloads', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_total', @@ -818,6 +833,7 @@ 'original_name': 'Active downloads', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_active', @@ -869,6 +885,7 @@ 'original_name': 'Downloads in queue', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_queue', @@ -924,6 +941,7 @@ 'original_name': 'Free space', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_free_space', @@ -979,6 +997,7 @@ 'original_name': 'Speed', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_speed', @@ -1030,6 +1049,7 @@ 'original_name': 'Total downloads', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_total', diff --git a/tests/components/pyload/snapshots/test_switch.ambr b/tests/components/pyload/snapshots/test_switch.ambr index 479013b09e4..b1f566fc8c8 100644 --- a/tests/components/pyload/snapshots/test_switch.ambr +++ b/tests/components/pyload/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Auto-Reconnect', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_reconnect', @@ -75,6 +76,7 @@ 'original_name': 'Pause/Resume queue', 'platform': 'pyload', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'XXXXXXXXXXXXXX_download', diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json index fc204c975ad..3a9e845bc26 100644 --- a/tests/components/qbus/fixtures/payload_config.json +++ b/tests/components/qbus/fixtures/payload_config.json @@ -99,6 +99,19 @@ "write": true } } + }, + { + "id": "UL25", + "location": "Living", + "locationId": 0, + "name": "Watching TV", + "originalName": "Watching TV", + "refId": "000001/105/3", + "type": "scene", + "actions": { + "active": null + }, + "properties": {} } ] } diff --git a/tests/components/qbus/test_scene.py b/tests/components/qbus/test_scene.py new file mode 100644 index 00000000000..8fdf60ec502 --- /dev/null +++ b/tests/components/qbus/test_scene.py @@ -0,0 +1,45 @@ +"""Test Qbus scene entities.""" + +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +_PAYLOAD_SCENE_STATE = '{"id":"UL25","properties":{"value":true},"type":"state"}' +_PAYLOAD_SCENE_ACTIVATE = '{"id": "UL25", "type": "action", "action": "active"}' + +_TOPIC_SCENE_STATE = "cloudapp/QBUSMQTTGW/UL1/UL25/state" +_TOPIC_SCENE_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL25/setState" + +_SCENE_ENTITY_ID = "scene.ctd_000001_watching_tv" + + +async def test_scene( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test scene.""" + + assert hass.states.get(_SCENE_ENTITY_ID).state == STATE_UNKNOWN + + # Activate scene + mqtt_mock.reset_mock() + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _SCENE_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_SCENE_SET_STATE, _PAYLOAD_SCENE_ACTIVATE, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_SCENE_STATE, _PAYLOAD_SCENE_STATE) + await hass.async_block_till_done() + + assert hass.states.get(_SCENE_ENTITY_ID).state != STATE_UNKNOWN diff --git a/tests/components/quantum_gateway/__init__.py b/tests/components/quantum_gateway/__init__.py new file mode 100644 index 00000000000..73758f9081e --- /dev/null +++ b/tests/components/quantum_gateway/__init__.py @@ -0,0 +1,22 @@ +"""Tests for the quantum_gateway component.""" + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def setup_platform(hass: HomeAssistant) -> None: + """Set up the quantum_gateway integration.""" + result = await async_setup_component( + hass, + DEVICE_TRACKER_DOMAIN, + { + DEVICE_TRACKER_DOMAIN: { + CONF_PLATFORM: "quantum_gateway", + CONF_PASSWORD: "fake_password", + } + }, + ) + await hass.async_block_till_done() + assert result diff --git a/tests/components/quantum_gateway/conftest.py b/tests/components/quantum_gateway/conftest.py new file mode 100644 index 00000000000..b2445813023 --- /dev/null +++ b/tests/components/quantum_gateway/conftest.py @@ -0,0 +1,23 @@ +"""Fixtures for Quantum Gateway tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +async def mock_scanner() -> Generator[AsyncMock]: + """Mock QuantumGatewayScanner instance.""" + with patch( + "homeassistant.components.quantum_gateway.device_tracker.QuantumGatewayScanner", + autospec=True, + ) as mock_scanner: + client = mock_scanner.return_value + client.success_init = True + client.scan_devices.return_value = ["ff:ff:ff:ff:ff:ff", "ff:ff:ff:ff:ff:fe"] + client.get_device_name.side_effect = { + "ff:ff:ff:ff:ff:ff": "", + "ff:ff:ff:ff:ff:fe": "desktop", + }.get + yield mock_scanner diff --git a/tests/components/quantum_gateway/test_device_tracker.py b/tests/components/quantum_gateway/test_device_tracker.py new file mode 100644 index 00000000000..df568d1f81a --- /dev/null +++ b/tests/components/quantum_gateway/test_device_tracker.py @@ -0,0 +1,51 @@ +"""Tests for the quantum_gateway device tracker.""" + +from unittest.mock import AsyncMock + +import pytest +from requests import RequestException + +from homeassistant.const import STATE_HOME +from homeassistant.core import HomeAssistant + +from . import setup_platform + +from tests.components.device_tracker.test_init import mock_yaml_devices # noqa: F401 + + +@pytest.mark.usefixtures("yaml_devices") +async def test_get_scanner(hass: HomeAssistant, mock_scanner: AsyncMock) -> None: + """Test creating a quantum gateway scanner.""" + await setup_platform(hass) + + device_1 = hass.states.get("device_tracker.desktop") + assert device_1 is not None + assert device_1.state == STATE_HOME + + device_2 = hass.states.get("device_tracker.ff_ff_ff_ff_ff_ff") + assert device_2 is not None + assert device_2.state == STATE_HOME + + +@pytest.mark.usefixtures("yaml_devices") +async def test_get_scanner_error(hass: HomeAssistant, mock_scanner: AsyncMock) -> None: + """Test failure when creating a quantum gateway scanner.""" + mock_scanner.side_effect = RequestException("Error") + await setup_platform(hass) + + assert "quantum_gateway.device_tracker" not in hass.config.components + + +@pytest.mark.usefixtures("yaml_devices") +async def test_scan_devices_error(hass: HomeAssistant, mock_scanner: AsyncMock) -> None: + """Test failure when scanning devices.""" + mock_scanner.return_value.scan_devices.side_effect = RequestException("Error") + await setup_platform(hass) + + assert "quantum_gateway.device_tracker" in hass.config.components + + device_1 = hass.states.get("device_tracker.desktop") + assert device_1 is None + + device_2 = hass.states.get("device_tracker.ff_ff_ff_ff_ff_ff") + assert device_2 is None diff --git a/tests/components/rainforest_raven/snapshots/test_sensor.ambr b/tests/components/rainforest_raven/snapshots/test_sensor.ambr index fc0d5862352..340248f6d8b 100644 --- a/tests/components/rainforest_raven/snapshots/test_sensor.ambr +++ b/tests/components/rainforest_raven/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Energy price', 'platform': 'rainforest_raven', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_price', 'unique_id': '1234567890abcdef.PriceCluster.price', @@ -76,12 +77,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power demand', 'platform': 'rainforest_raven', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_demand', 'unique_id': '1234567890abcdef.InstantaneousDemand.demand', @@ -134,6 +139,7 @@ 'original_name': 'Signal strength', 'platform': 'rainforest_raven', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'signal_strength', 'unique_id': 'abcdef0123456789.NetworkInfo.link_strength', @@ -180,12 +186,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy delivered', 'platform': 'rainforest_raven', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_delivered', 'unique_id': '1234567890abcdef.CurrentSummationDelivered.summation_delivered', @@ -232,12 +242,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy received', 'platform': 'rainforest_raven', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_received', 'unique_id': '1234567890abcdef.CurrentSummationDelivered.summation_received', diff --git a/tests/components/rainmachine/snapshots/test_binary_sensor.ambr b/tests/components/rainmachine/snapshots/test_binary_sensor.ambr index c4d6f2eeae1..1e7e15f2a49 100644 --- a/tests/components/rainmachine/snapshots/test_binary_sensor.ambr +++ b/tests/components/rainmachine/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Freeze restrictions', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'freeze', 'unique_id': 'aa:bb:cc:dd:ee:ff_freeze', @@ -74,6 +75,7 @@ 'original_name': 'Hourly restrictions', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hourly', 'unique_id': 'aa:bb:cc:dd:ee:ff_hourly', @@ -121,6 +123,7 @@ 'original_name': 'Month restrictions', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'month', 'unique_id': 'aa:bb:cc:dd:ee:ff_month', @@ -168,6 +171,7 @@ 'original_name': 'Rain delay restrictions', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'raindelay', 'unique_id': 'aa:bb:cc:dd:ee:ff_raindelay', @@ -215,6 +219,7 @@ 'original_name': 'Rain sensor restrictions', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rainsensor', 'unique_id': 'aa:bb:cc:dd:ee:ff_rainsensor', @@ -262,6 +267,7 @@ 'original_name': 'Weekday restrictions', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'weekday', 'unique_id': 'aa:bb:cc:dd:ee:ff_weekday', diff --git a/tests/components/rainmachine/snapshots/test_button.ambr b/tests/components/rainmachine/snapshots/test_button.ambr index 68f83d9286a..8126c190a8d 100644 --- a/tests/components/rainmachine/snapshots/test_button.ambr +++ b/tests/components/rainmachine/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Restart', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_reboot', diff --git a/tests/components/rainmachine/snapshots/test_select.ambr b/tests/components/rainmachine/snapshots/test_select.ambr index d150f8c31b5..4b4ba86bb2e 100644 --- a/tests/components/rainmachine/snapshots/test_select.ambr +++ b/tests/components/rainmachine/snapshots/test_select.ambr @@ -34,6 +34,7 @@ 'original_name': 'Freeze protection temperature', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'freeze_protection_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff_freeze_protection_temperature', diff --git a/tests/components/rainmachine/snapshots/test_sensor.ambr b/tests/components/rainmachine/snapshots/test_sensor.ambr index 2475abecb51..4b9c98483ae 100644 --- a/tests/components/rainmachine/snapshots/test_sensor.ambr +++ b/tests/components/rainmachine/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Evening Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_program_run_completion_time_2', @@ -75,6 +76,7 @@ 'original_name': 'Flower Box Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_2', @@ -123,6 +125,7 @@ 'original_name': 'Landscaping Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_1', @@ -171,6 +174,7 @@ 'original_name': 'Morning Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_program_run_completion_time_1', @@ -219,6 +223,7 @@ 'original_name': 'Rain sensor rain start', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rain_sensor_rain_start', 'unique_id': 'aa:bb:cc:dd:ee:ff_rain_sensor_rain_start', @@ -268,6 +273,7 @@ 'original_name': 'TEST Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_3', @@ -316,6 +322,7 @@ 'original_name': 'Zone 10 Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_10', @@ -364,6 +371,7 @@ 'original_name': 'Zone 11 Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_11', @@ -412,6 +420,7 @@ 'original_name': 'Zone 12 Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_12', @@ -460,6 +469,7 @@ 'original_name': 'Zone 4 Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_4', @@ -508,6 +518,7 @@ 'original_name': 'Zone 5 Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_5', @@ -556,6 +567,7 @@ 'original_name': 'Zone 6 Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_6', @@ -604,6 +616,7 @@ 'original_name': 'Zone 7 Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_7', @@ -652,6 +665,7 @@ 'original_name': 'Zone 8 Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_8', @@ -700,6 +714,7 @@ 'original_name': 'Zone 9 Run Completion Time', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_9', diff --git a/tests/components/rainmachine/snapshots/test_switch.ambr b/tests/components/rainmachine/snapshots/test_switch.ambr index d40913a7eb0..5ef256bc408 100644 --- a/tests/components/rainmachine/snapshots/test_switch.ambr +++ b/tests/components/rainmachine/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Evening', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_program_2', @@ -100,6 +101,7 @@ 'original_name': 'Evening enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_program_2_enabled', @@ -149,6 +151,7 @@ 'original_name': 'Extra water on hot days', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hot_days_extra_watering', 'unique_id': 'aa:bb:cc:dd:ee:ff_hot_days_extra_watering', @@ -197,6 +200,7 @@ 'original_name': 'Flower box', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_2', @@ -259,6 +263,7 @@ 'original_name': 'Flower box enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_2_enabled', @@ -308,6 +313,7 @@ 'original_name': 'Freeze protection', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'freeze_protect_enabled', 'unique_id': 'aa:bb:cc:dd:ee:ff_freeze_protect_enabled', @@ -356,6 +362,7 @@ 'original_name': 'Landscaping', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_1', @@ -418,6 +425,7 @@ 'original_name': 'Landscaping enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_1_enabled', @@ -467,6 +475,7 @@ 'original_name': 'Morning', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_program_1', @@ -540,6 +549,7 @@ 'original_name': 'Morning enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_program_1_enabled', @@ -589,6 +599,7 @@ 'original_name': 'Test', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_3', @@ -651,6 +662,7 @@ 'original_name': 'Test enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_3_enabled', @@ -700,6 +712,7 @@ 'original_name': 'Zone 10', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_10', @@ -762,6 +775,7 @@ 'original_name': 'Zone 10 enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_10_enabled', @@ -811,6 +825,7 @@ 'original_name': 'Zone 11', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_11', @@ -873,6 +888,7 @@ 'original_name': 'Zone 11 enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_11_enabled', @@ -922,6 +938,7 @@ 'original_name': 'Zone 12', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_12', @@ -984,6 +1001,7 @@ 'original_name': 'Zone 12 enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_12_enabled', @@ -1033,6 +1051,7 @@ 'original_name': 'Zone 4', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_4', @@ -1095,6 +1114,7 @@ 'original_name': 'Zone 4 enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_4_enabled', @@ -1144,6 +1164,7 @@ 'original_name': 'Zone 5', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_5', @@ -1206,6 +1227,7 @@ 'original_name': 'Zone 5 enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_5_enabled', @@ -1255,6 +1277,7 @@ 'original_name': 'Zone 6', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_6', @@ -1317,6 +1340,7 @@ 'original_name': 'Zone 6 enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_6_enabled', @@ -1366,6 +1390,7 @@ 'original_name': 'Zone 7', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_7', @@ -1428,6 +1453,7 @@ 'original_name': 'Zone 7 enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_7_enabled', @@ -1477,6 +1503,7 @@ 'original_name': 'Zone 8', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_8', @@ -1539,6 +1566,7 @@ 'original_name': 'Zone 8 enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_8_enabled', @@ -1588,6 +1616,7 @@ 'original_name': 'Zone 9', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_9', @@ -1650,6 +1679,7 @@ 'original_name': 'Zone 9 enabled', 'platform': 'rainmachine', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_9_enabled', diff --git a/tests/components/rainmachine/test_binary_sensor.py b/tests/components/rainmachine/test_binary_sensor.py index d428993da51..55736f118b3 100644 --- a/tests/components/rainmachine/test_binary_sensor.py +++ b/tests/components/rainmachine/test_binary_sensor.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rainmachine/test_button.py b/tests/components/rainmachine/test_button.py index 629c325c79e..a9d4042bf8f 100644 --- a/tests/components/rainmachine/test_button.py +++ b/tests/components/rainmachine/test_button.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py index ad5743957dd..65cf45810a3 100644 --- a/tests/components/rainmachine/test_diagnostics.py +++ b/tests/components/rainmachine/test_diagnostics.py @@ -1,7 +1,7 @@ """Test RainMachine diagnostics.""" from regenmaschine.errors import RainMachineError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/rainmachine/test_select.py b/tests/components/rainmachine/test_select.py index ca9ce2e644d..31768313c0b 100644 --- a/tests/components/rainmachine/test_select.py +++ b/tests/components/rainmachine/test_select.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rainmachine/test_sensor.py b/tests/components/rainmachine/test_sensor.py index 3ff533b6da0..15bb87a8151 100644 --- a/tests/components/rainmachine/test_sensor.py +++ b/tests/components/rainmachine/test_sensor.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rainmachine/test_switch.py b/tests/components/rainmachine/test_switch.py index 50e73a78efe..cc0552a15f1 100644 --- a/tests/components/rainmachine/test_switch.py +++ b/tests/components/rainmachine/test_switch.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rdw/test_diagnostics.py b/tests/components/rdw/test_diagnostics.py index a5e8c72dba1..0f4a2279993 100644 --- a/tests/components/rdw/test_diagnostics.py +++ b/tests/components/rdw/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the RDW integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ed754723426..a8d8ed61020 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -2,12 +2,15 @@ from collections.abc import Generator from datetime import timedelta +import re from typing import Any from unittest.mock import ANY, Mock, patch import pytest from sqlalchemy import select +import voluptuous as vol +from homeassistant import exceptions from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history, statistics from homeassistant.components.recorder.db_schema import StatisticsShortTerm @@ -40,7 +43,7 @@ from homeassistant.components.recorder.table_managers.statistics_meta import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import UNIT_CONVERTERS -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -56,7 +59,7 @@ from .common import ( statistics_during_period, ) -from tests.common import MockPlatform, mock_platform +from tests.common import MockPlatform, MockUser, mock_platform from tests.typing import RecorderInstanceContextManager, WebSocketGenerator @@ -3421,3 +3424,319 @@ async def test_recorder_platform_with_partial_statistics_support( for meth in supported_methods: getattr(recorder_platform, meth).assert_called_once() + + +@pytest.mark.parametrize( + ("service_args", "expected_result"), + [ + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "hour", + "statistic_ids": ["sensor.i_dont_exist"], + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + {"statistics": {}}, + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "hour", + "statistic_ids": [ + "sensor.total_energy_import1", + "sensor.total_energy_import2", + ], + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + { + "statistics": { + "sensor.total_energy_import1": [ + { + "last_reset": "2021-12-31T22:00:00+00:00", + "change": 2.0, + "end": "2023-05-08T08:00:00+00:00", + "start": "2023-05-08T07:00:00+00:00", + "state": 0.0, + "sum": 2.0, + "min": 0.0, + "max": 10.0, + "mean": 1.0, + }, + { + "change": 1.0, + "end": "2023-05-08T09:00:00+00:00", + "start": "2023-05-08T08:00:00+00:00", + "state": 1.0, + "sum": 3.0, + "min": 1.0, + "max": 11.0, + "mean": 1.0, + }, + { + "change": 2.0, + "end": "2023-05-08T10:00:00+00:00", + "start": "2023-05-08T09:00:00+00:00", + "state": 2.0, + "sum": 5.0, + "min": 2.0, + "max": 12.0, + "mean": 1.0, + }, + { + "change": 3.0, + "end": "2023-05-08T11:00:00+00:00", + "start": "2023-05-08T10:00:00+00:00", + "state": 3.0, + "sum": 8.0, + "min": 3.0, + "max": 13.0, + "mean": 1.0, + }, + ], + "sensor.total_energy_import2": [ + { + "last_reset": "2021-12-31T22:00:00+00:00", + "change": 2.0, + "end": "2023-05-08T08:00:00+00:00", + "start": "2023-05-08T07:00:00+00:00", + "state": 0.0, + "sum": 2.0, + "min": 0.0, + "max": 10.0, + "mean": 1.0, + }, + { + "change": 1.0, + "end": "2023-05-08T09:00:00+00:00", + "start": "2023-05-08T08:00:00+00:00", + "state": 1.0, + "sum": 3.0, + "min": 1.0, + "max": 11.0, + "mean": 1.0, + }, + { + "change": 2.0, + "end": "2023-05-08T10:00:00+00:00", + "start": "2023-05-08T09:00:00+00:00", + "state": 2.0, + "sum": 5.0, + "min": 2.0, + "max": 12.0, + "mean": 1.0, + }, + { + "change": 3.0, + "end": "2023-05-08T11:00:00+00:00", + "start": "2023-05-08T10:00:00+00:00", + "state": 3.0, + "sum": 8.0, + "min": 3.0, + "max": 13.0, + "mean": 1.0, + }, + ], + } + }, + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "day", + "statistic_ids": [ + "sensor.total_energy_import1", + "sensor.total_energy_import2", + ], + "types": ["sum"], + }, + { + "statistics": { + "sensor.total_energy_import1": [ + { + "start": "2023-05-08T07:00:00+00:00", + "end": "2023-05-09T07:00:00+00:00", + "sum": 8.0, + } + ], + "sensor.total_energy_import2": [ + { + "start": "2023-05-08T07:00:00+00:00", + "end": "2023-05-09T07:00:00+00:00", + "sum": 8.0, + } + ], + } + }, + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "end_time": "2023-05-08 08:00:00Z", + "period": "hour", + "types": ["change", "sum"], + "statistic_ids": ["sensor.total_energy_import1"], + "units": {"energy": "Wh"}, + }, + { + "statistics": { + "sensor.total_energy_import1": [ + { + "start": "2023-05-08T07:00:00+00:00", + "end": "2023-05-08T08:00:00+00:00", + "change": 2000.0, + "sum": 2000.0, + }, + ], + } + }, + ), + ], +) +@pytest.mark.usefixtures("recorder_mock") +async def test_get_statistics_service( + hass: HomeAssistant, + hass_read_only_user: MockUser, + service_args: dict[str, Any], + expected_result: dict[str, Any], +) -> None: + """Test the get_statistics service.""" + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + last_reset = dt_util.parse_datetime("2022-01-01T00:00:00+02:00") + external_statistics = ( + { + "start": period1, + "state": 0, + "sum": 2, + "min": 0, + "max": 10, + "mean": 1, + "last_reset": last_reset, + }, + { + "start": period2, + "state": 1, + "sum": 3, + "min": 1, + "max": 11, + "mean": 1, + "last_reset": None, + }, + { + "start": period3, + "state": 2, + "sum": 5, + "min": 2, + "max": 12, + "mean": 1, + "last_reset": None, + }, + { + "start": period4, + "state": 3, + "sum": 8, + "min": 3, + "max": 13, + "mean": 1, + "last_reset": None, + }, + ) + external_metadata1 = { + "has_mean": True, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import1", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": True, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import2", + "unit_of_measurement": "kWh", + } + async_import_statistics(hass, external_metadata1, external_statistics) + async_import_statistics(hass, external_metadata2, external_statistics) + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + result = await hass.services.async_call( + "recorder", "get_statistics", service_args, return_response=True, blocking=True + ) + assert result == expected_result + + with pytest.raises(exceptions.Unauthorized): + result = await hass.services.async_call( + "recorder", + "get_statistics", + service_args, + return_response=True, + blocking=True, + context=Context(user_id=hass_read_only_user.id), + ) + + +@pytest.mark.parametrize( + ("service_args", "missing_key"), + [ + ( + { + "period": "hour", + "statistic_ids": ["sensor.sensor"], + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + "start_time", + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "hour", + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + "statistic_ids", + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "statistic_ids": ["sensor.sensor"], + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + "period", + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "hour", + "statistic_ids": ["sensor.sensor"], + }, + "types", + ), + ], +) +@pytest.mark.usefixtures("recorder_mock") +async def test_get_statistics_service_missing_mandatory_keys( + hass: HomeAssistant, + service_args: dict[str, Any], + missing_key: str, +) -> None: + """Test the get_statistics service with missing mandatory keys.""" + + await async_recorder_block_till_done(hass) + + with pytest.raises( + vol.error.MultipleInvalid, + match=re.escape(f"required key not provided @ data['{missing_key}']"), + ): + await hass.services.async_call( + "recorder", + "get_statistics", + service_args, + return_response=True, + blocking=True, + ) diff --git a/tests/components/rehlko/fixtures/generator.json b/tests/components/rehlko/fixtures/generator.json index fa1d4d0b45b..5741b470bc6 100644 --- a/tests/components/rehlko/fixtures/generator.json +++ b/tests/components/rehlko/fixtures/generator.json @@ -54,8 +54,8 @@ "alertCount": 0, "model": "Model20KW", "modelDisplayName": "20 KW", - "lastMaintenanceTimestamp": "2025-04-10T09:12:59", - "nextMaintenanceTimestamp": "2026-04-10T09:12:59", + "lastMaintenanceTimestamp": "2025-04-10T09:12:59-04:00", + "nextMaintenanceTimestamp": "2026-04-10T09:12:59-04:00", "maintenancePeriodDays": 365, "hasServiceAgreement": null, "totalRuntimeHours": 120.2 @@ -74,7 +74,7 @@ }, "exercise": { "frequency": "Weekly", - "nextStartTimestamp": "2025-04-19T10:00:00", + "nextStartTimestamp": "2025-04-19T10:00:00-04:00", "mode": "Unloaded", "runningMode": null, "durationMinutes": 20, diff --git a/tests/components/rehlko/snapshots/test_binary_sensor.ambr b/tests/components/rehlko/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..38b5b048d08 --- /dev/null +++ b/tests/components/rehlko/snapshots/test_binary_sensor.ambr @@ -0,0 +1,147 @@ +# serializer version: 1 +# name: test_sensors[binary_sensor.generator_1_auto_run-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.generator_1_auto_run', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto run', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_run', + 'unique_id': 'myemail@email.com_12345_switchState', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.generator_1_auto_run-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Auto run', + }), + 'context': , + 'entity_id': 'binary_sensor.generator_1_auto_run', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[binary_sensor.generator_1_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': , + 'entity_id': 'binary_sensor.generator_1_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': 'rehlko', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'myemail@email.com_12345_isConnected', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.generator_1_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Generator 1 Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.generator_1_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[binary_sensor.generator_1_oil_pressure-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.generator_1_oil_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil pressure', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oil_pressure', + 'unique_id': 'myemail@email.com_12345_engineOilPressureOk', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.generator_1_oil_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Generator 1 Oil pressure', + }), + 'context': , + 'entity_id': 'binary_sensor.generator_1_oil_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/rehlko/snapshots/test_sensor.ambr b/tests/components/rehlko/snapshots/test_sensor.ambr index 3973996ba80..d20b916d3ea 100644 --- a/tests/components/rehlko/snapshots/test_sensor.ambr +++ b/tests/components/rehlko/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery voltage', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_voltage', 'unique_id': 'myemail@email.com_12345_batteryVoltageV', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Controller temperature', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'controller_temperature', 'unique_id': 'myemail@email.com_12345_controllerTempF', @@ -131,6 +139,7 @@ 'original_name': 'Device IP address', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_ip_address', 'unique_id': 'myemail@email.com_12345_deviceIpAddress', @@ -174,12 +183,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Engine compartment temperature', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'engine_compartment_temperature', 'unique_id': 'myemail@email.com_12345_engineCompartmentTempF', @@ -226,12 +239,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Engine coolant temperature', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'engine_coolant_temperature', 'unique_id': 'myemail@email.com_12345_engineCoolantTempF', @@ -278,12 +295,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Engine frequency', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'engine_frequency', 'unique_id': 'myemail@email.com_12345_engineFrequencyHz', @@ -330,6 +351,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -339,6 +363,7 @@ 'original_name': 'Engine oil pressure', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'engine_oil_pressure', 'unique_id': 'myemail@email.com_12345_engineOilPressurePsi', @@ -391,6 +416,7 @@ 'original_name': 'Engine speed', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'engine_speed', 'unique_id': 'myemail@email.com_12345_engineSpeedRpm', @@ -440,6 +466,7 @@ 'original_name': 'Engine state', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'engine_state', 'unique_id': 'myemail@email.com_12345_engineState', @@ -483,12 +510,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Generator load', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'generator_load', 'unique_id': 'myemail@email.com_12345_generatorLoadW', @@ -541,6 +572,7 @@ 'original_name': 'Generator load percentage', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'generator_load_percent', 'unique_id': 'myemail@email.com_12345_generatorLoadPercent', @@ -590,6 +622,7 @@ 'original_name': 'Generator status', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'generator_status', 'unique_id': 'myemail@email.com_12345_status', @@ -609,6 +642,153 @@ 'state': 'ReadyToRun', }) # --- +# name: test_sensors[sensor.generator_1_last_exercise-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_last_exercise', + '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 exercise', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_exercise', + 'unique_id': 'myemail@email.com_12345_lastStartTimestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_last_exercise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Generator 1 Last exercise', + }), + 'context': , + 'entity_id': 'sensor.generator_1_last_exercise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-12T14:00:00+00:00', + }) +# --- +# name: test_sensors[sensor.generator_1_last_maintainance-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_last_maintainance', + '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 maintainance', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_maintainance', + 'unique_id': 'myemail@email.com_12345_lastMaintenanceTimestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_last_maintainance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Generator 1 Last maintainance', + }), + 'context': , + 'entity_id': 'sensor.generator_1_last_maintainance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-10T13:12:59+00:00', + }) +# --- +# name: test_sensors[sensor.generator_1_last_run-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_last_run', + '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 run', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_run', + 'unique_id': 'myemail@email.com_12345_lastRanTimestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_last_run-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Generator 1 Last run', + }), + 'context': , + 'entity_id': 'sensor.generator_1_last_run', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-12T14:00:00+00:00', + }) +# --- # name: test_sensors[sensor.generator_1_lube_oil_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -633,12 +813,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Lube oil temperature', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lube_oil_temperature', 'unique_id': 'myemail@email.com_12345_lubeOilTempF', @@ -661,6 +845,104 @@ 'state': '6.0', }) # --- +# name: test_sensors[sensor.generator_1_next_exercise-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_next_exercise', + '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 exercise', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_exercise', + 'unique_id': 'myemail@email.com_12345_nextStartTimestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_next_exercise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Generator 1 Next exercise', + }), + 'context': , + 'entity_id': 'sensor.generator_1_next_exercise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-19T14:00:00+00:00', + }) +# --- +# name: test_sensors[sensor.generator_1_next_maintainance-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_next_maintainance', + '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 maintainance', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_maintainance', + 'unique_id': 'myemail@email.com_12345_nextMaintenanceTimestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_next_maintainance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Generator 1 Next maintainance', + }), + 'context': , + 'entity_id': 'sensor.generator_1_next_maintainance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-04-10T13:12:59+00:00', + }) +# --- # name: test_sensors[sensor.generator_1_power_source-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -689,6 +971,7 @@ 'original_name': 'Power source', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_source', 'unique_id': 'myemail@email.com_12345_powerSource', @@ -732,12 +1015,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Runtime since last maintenance', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'runtime_since_last_maintenance', 'unique_id': 'myemail@email.com_12345_runtimeSinceLastMaintenanceHours', @@ -788,6 +1075,7 @@ 'original_name': 'Server IP address', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'server_ip_address', 'unique_id': 'myemail@email.com_12345_serverIpAddress', @@ -831,12 +1119,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total operation', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_operation', 'unique_id': 'myemail@email.com_12345_totalOperationHours', @@ -883,12 +1175,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total runtime', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_runtime', 'unique_id': 'myemail@email.com_12345_totalRuntimeHours', @@ -935,12 +1231,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Utility voltage', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'utility_voltage', 'unique_id': 'myemail@email.com_12345_utilityVoltageV', @@ -987,12 +1287,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'rehlko', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'generator_voltage_avg', 'unique_id': 'myemail@email.com_12345_generatorVoltageAvgV', diff --git a/tests/components/rehlko/test_binary_sensor.py b/tests/components/rehlko/test_binary_sensor.py new file mode 100644 index 00000000000..8834635f716 --- /dev/null +++ b/tests/components/rehlko/test_binary_sensor.py @@ -0,0 +1,93 @@ +"""Tests for the Rehlko binary sensors.""" + +from __future__ import annotations + +import logging +from typing import Any +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.rehlko.const import GENERATOR_DATA_DEVICE +from homeassistant.components.rehlko.coordinator import SCAN_INTERVAL_MINUTES +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 tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(name="platform_binary_sensor", autouse=True) +async def platform_binary_sensor_fixture(): + """Patch Rehlko to only load binary_sensor platform.""" + with patch("homeassistant.components.rehlko.PLATFORMS", [Platform.BINARY_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 binary sensors.""" + await snapshot_platform( + hass, entity_registry, snapshot, rehlko_config_entry.entry_id + ) + + +async def test_binary_sensor_states( + hass: HomeAssistant, + generator: dict[str, Any], + mock_rehlko: AsyncMock, + load_rehlko_config_entry: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the Rehlko binary sensor state logic.""" + assert generator["engineOilPressureOk"] is True + state = hass.states.get("binary_sensor.generator_1_oil_pressure") + assert state.state == STATE_OFF + + generator["engineOilPressureOk"] = False + freezer.tick(SCAN_INTERVAL_MINUTES) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.generator_1_oil_pressure") + assert state.state == STATE_ON + + generator["engineOilPressureOk"] = "Unknown State" + with caplog.at_level(logging.WARNING): + caplog.clear() + freezer.tick(SCAN_INTERVAL_MINUTES) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.generator_1_oil_pressure") + assert state.state == STATE_UNKNOWN + assert "Unknown State" in caplog.text + assert "engineOilPressureOk" in caplog.text + + +async def test_binary_sensor_connectivity_availability( + hass: HomeAssistant, + generator: dict[str, Any], + mock_rehlko: AsyncMock, + load_rehlko_config_entry: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the connectivity entity availability when device is disconnected.""" + state = hass.states.get("binary_sensor.generator_1_connectivity") + assert state.state == STATE_ON + + # Entity should be available when device is disconnected + generator[GENERATOR_DATA_DEVICE]["isConnected"] = False + freezer.tick(SCAN_INTERVAL_MINUTES) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.generator_1_connectivity") + assert state.state == STATE_OFF diff --git a/tests/components/rehlko/test_sensor.py b/tests/components/rehlko/test_sensor.py index ef3d9d1cf6a..ce361678a59 100644 --- a/tests/components/rehlko/test_sensor.py +++ b/tests/components/rehlko/test_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rehlko.coordinator import SCAN_INTERVAL_MINUTES from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index e89873593e9..cee29a76dca 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe40vin_charging', @@ -75,6 +76,7 @@ 'original_name': 'HVAC', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1zoe40vin_hvac_status', @@ -122,6 +124,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe40vin_plugged_in', @@ -170,6 +173,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe40vin_charging', @@ -218,6 +222,7 @@ 'original_name': 'HVAC', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1zoe40vin_hvac_status', @@ -265,6 +270,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe40vin_plugged_in', @@ -313,6 +319,7 @@ 'original_name': 'Driver door', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1capturfuelvin_driver_door_status', @@ -361,6 +368,7 @@ 'original_name': 'Hatch', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1capturfuelvin_hatch_status', @@ -409,6 +417,7 @@ 'original_name': 'Lock', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1capturfuelvin_lock_status', @@ -457,6 +466,7 @@ 'original_name': 'Passenger door', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1capturfuelvin_passenger_door_status', @@ -505,6 +515,7 @@ 'original_name': 'Rear left door', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1capturfuelvin_rear_left_door_status', @@ -553,6 +564,7 @@ 'original_name': 'Rear right door', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1capturfuelvin_rear_right_door_status', @@ -601,6 +613,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1capturphevvin_charging', @@ -649,6 +662,7 @@ 'original_name': 'Driver door', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1capturphevvin_driver_door_status', @@ -697,6 +711,7 @@ 'original_name': 'Hatch', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1capturphevvin_hatch_status', @@ -745,6 +760,7 @@ 'original_name': 'Lock', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1capturphevvin_lock_status', @@ -793,6 +809,7 @@ 'original_name': 'Passenger door', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1capturphevvin_passenger_door_status', @@ -841,6 +858,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1capturphevvin_plugged_in', @@ -889,6 +907,7 @@ 'original_name': 'Rear left door', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1capturphevvin_rear_left_door_status', @@ -937,6 +956,7 @@ 'original_name': 'Rear right door', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1capturphevvin_rear_right_door_status', @@ -985,6 +1005,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1twingoiiivin_charging', @@ -1033,6 +1054,7 @@ 'original_name': 'HVAC', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1twingoiiivin_hvac_status', @@ -1080,6 +1102,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1twingoiiivin_plugged_in', @@ -1128,6 +1151,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe40vin_charging', @@ -1176,6 +1200,7 @@ 'original_name': 'HVAC', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1zoe40vin_hvac_status', @@ -1223,6 +1248,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe40vin_plugged_in', @@ -1271,6 +1297,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe50vin_charging', @@ -1319,6 +1346,7 @@ 'original_name': 'HVAC', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1zoe50vin_hvac_status', @@ -1366,6 +1394,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe50vin_plugged_in', diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index 1c7d5f80af2..95e81aee4c5 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Start air conditioner', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1zoe40vin_start_air_conditioner', @@ -74,6 +75,7 @@ 'original_name': 'Start charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1zoe40vin_start_charge', @@ -121,6 +123,7 @@ 'original_name': 'Stop charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1zoe40vin_stop_charge', @@ -168,6 +171,7 @@ 'original_name': 'Start air conditioner', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1zoe40vin_start_air_conditioner', @@ -215,6 +219,7 @@ 'original_name': 'Start charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1zoe40vin_start_charge', @@ -262,6 +267,7 @@ 'original_name': 'Stop charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1zoe40vin_stop_charge', @@ -309,6 +315,7 @@ 'original_name': 'Start air conditioner', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1zoe40vin_start_air_conditioner', @@ -356,6 +363,7 @@ 'original_name': 'Start charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1zoe40vin_start_charge', @@ -403,6 +411,7 @@ 'original_name': 'Stop charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1zoe40vin_stop_charge', @@ -450,6 +459,7 @@ 'original_name': 'Start air conditioner', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1zoe40vin_start_air_conditioner', @@ -497,6 +507,7 @@ 'original_name': 'Start charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1zoe40vin_start_charge', @@ -544,6 +555,7 @@ 'original_name': 'Stop charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1zoe40vin_stop_charge', @@ -591,6 +603,7 @@ 'original_name': 'Start air conditioner', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1capturfuelvin_start_air_conditioner', @@ -638,6 +651,7 @@ 'original_name': 'Start air conditioner', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1capturphevvin_start_air_conditioner', @@ -685,6 +699,7 @@ 'original_name': 'Start charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1capturphevvin_start_charge', @@ -732,6 +747,7 @@ 'original_name': 'Stop charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1capturphevvin_stop_charge', @@ -779,6 +795,7 @@ 'original_name': 'Start air conditioner', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1twingoiiivin_start_air_conditioner', @@ -826,6 +843,7 @@ 'original_name': 'Start charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1twingoiiivin_start_charge', @@ -873,6 +891,7 @@ 'original_name': 'Stop charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1twingoiiivin_stop_charge', @@ -920,6 +939,7 @@ 'original_name': 'Start air conditioner', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1zoe40vin_start_air_conditioner', @@ -967,6 +987,7 @@ 'original_name': 'Start charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1zoe40vin_start_charge', @@ -1014,6 +1035,7 @@ 'original_name': 'Stop charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1zoe40vin_stop_charge', @@ -1061,6 +1083,7 @@ 'original_name': 'Start air conditioner', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1zoe50vin_start_air_conditioner', @@ -1108,6 +1131,7 @@ 'original_name': 'Start charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1zoe50vin_start_charge', @@ -1155,6 +1179,7 @@ 'original_name': 'Stop charge', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1zoe50vin_stop_charge', diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index 7a35f70b51c..15f95140a8f 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': 'Location', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1zoe50vin_location', @@ -75,6 +76,7 @@ 'original_name': 'Location', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1zoe50vin_location', @@ -122,6 +124,7 @@ 'original_name': 'Location', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1capturfuelvin_location', @@ -173,6 +176,7 @@ 'original_name': 'Location', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1capturphevvin_location', @@ -224,6 +228,7 @@ 'original_name': 'Location', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1twingoiiivin_location', @@ -275,6 +280,7 @@ 'original_name': 'Location', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1zoe50vin_location', diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index 9df17d0a3ec..e0a1c779fc8 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -34,6 +34,7 @@ 'original_name': 'Charge mode', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1zoe40vin_charge_mode', @@ -94,6 +95,7 @@ 'original_name': 'Charge mode', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1zoe40vin_charge_mode', @@ -154,6 +156,7 @@ 'original_name': 'Charge mode', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1capturphevvin_charge_mode', @@ -214,6 +217,7 @@ 'original_name': 'Charge mode', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1twingoiiivin_charge_mode', @@ -274,6 +278,7 @@ 'original_name': 'Charge mode', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1zoe40vin_charge_mode', @@ -334,6 +339,7 @@ 'original_name': 'Charge mode', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1zoe50vin_charge_mode', diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index b6c9569e0d3..908b3ab9032 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe40vin_battery_level', @@ -75,12 +76,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery autonomy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1zoe40vin_battery_autonomy', @@ -127,12 +132,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1zoe40vin_battery_available_energy', @@ -179,12 +188,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1zoe40vin_battery_temperature', @@ -246,6 +259,7 @@ 'original_name': 'Charge state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1zoe40vin_charge_state', @@ -300,12 +314,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging power', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_power', 'unique_id': 'vf1zoe40vin_charging_power', @@ -352,12 +370,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging remaining time', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1zoe40vin_charging_remaining_time', @@ -408,6 +430,7 @@ 'original_name': 'HVAC SoC threshold', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1zoe40vin_hvac_soc_threshold', @@ -456,6 +479,7 @@ 'original_name': 'Last battery activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1zoe40vin_battery_last_activity', @@ -504,6 +528,7 @@ 'original_name': 'Last HVAC activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1zoe40vin_hvac_last_activity', @@ -548,12 +573,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1zoe40vin_mileage', @@ -600,12 +629,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1zoe40vin_outside_temperature', @@ -664,6 +697,7 @@ 'original_name': 'Plug state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1zoe40vin_plug_state', @@ -721,6 +755,7 @@ 'original_name': 'Battery', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe40vin_battery_level', @@ -767,12 +802,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery autonomy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1zoe40vin_battery_autonomy', @@ -819,12 +858,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1zoe40vin_battery_available_energy', @@ -871,12 +914,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1zoe40vin_battery_temperature', @@ -938,6 +985,7 @@ 'original_name': 'Charge state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1zoe40vin_charge_state', @@ -992,12 +1040,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging power', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_power', 'unique_id': 'vf1zoe40vin_charging_power', @@ -1044,12 +1096,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging remaining time', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1zoe40vin_charging_remaining_time', @@ -1100,6 +1156,7 @@ 'original_name': 'HVAC SoC threshold', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1zoe40vin_hvac_soc_threshold', @@ -1148,6 +1205,7 @@ 'original_name': 'Last battery activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1zoe40vin_battery_last_activity', @@ -1196,6 +1254,7 @@ 'original_name': 'Last HVAC activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1zoe40vin_hvac_last_activity', @@ -1240,12 +1299,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1zoe40vin_mileage', @@ -1292,12 +1355,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1zoe40vin_outside_temperature', @@ -1356,6 +1423,7 @@ 'original_name': 'Plug state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1zoe40vin_plug_state', @@ -1407,12 +1475,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Fuel autonomy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1capturfuelvin_fuel_autonomy', @@ -1459,12 +1531,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Fuel quantity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1capturfuelvin_fuel_quantity', @@ -1515,6 +1591,7 @@ 'original_name': 'Last location activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1capturfuelvin_location_last_activity', @@ -1559,12 +1636,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1capturfuelvin_mileage', @@ -1615,6 +1696,7 @@ 'original_name': 'Remote engine start', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1capturfuelvin_res_state', @@ -1662,6 +1744,7 @@ 'original_name': 'Remote engine start code', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1capturfuelvin_res_state_code', @@ -1705,12 +1788,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1capturphevvin_charging_power', @@ -1763,6 +1850,7 @@ 'original_name': 'Battery', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1capturphevvin_battery_level', @@ -1809,12 +1897,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery autonomy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1capturphevvin_battery_autonomy', @@ -1861,12 +1953,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1capturphevvin_battery_available_energy', @@ -1913,12 +2009,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1capturphevvin_battery_temperature', @@ -1980,6 +2080,7 @@ 'original_name': 'Charge state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1capturphevvin_charge_state', @@ -2034,12 +2135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging remaining time', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1capturphevvin_charging_remaining_time', @@ -2086,12 +2191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Fuel autonomy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1capturphevvin_fuel_autonomy', @@ -2138,12 +2247,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Fuel quantity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1capturphevvin_fuel_quantity', @@ -2194,6 +2307,7 @@ 'original_name': 'Last battery activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1capturphevvin_battery_last_activity', @@ -2242,6 +2356,7 @@ 'original_name': 'Last location activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1capturphevvin_location_last_activity', @@ -2286,12 +2401,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1capturphevvin_mileage', @@ -2350,6 +2469,7 @@ 'original_name': 'Plug state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1capturphevvin_plug_state', @@ -2405,6 +2525,7 @@ 'original_name': 'Remote engine start', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1capturphevvin_res_state', @@ -2452,6 +2573,7 @@ 'original_name': 'Remote engine start code', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1capturphevvin_res_state_code', @@ -2495,12 +2617,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1twingoiiivin_charging_power', @@ -2553,6 +2679,7 @@ 'original_name': 'Battery', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1twingoiiivin_battery_level', @@ -2599,12 +2726,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery autonomy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1twingoiiivin_battery_autonomy', @@ -2651,12 +2782,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1twingoiiivin_battery_available_energy', @@ -2703,12 +2838,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1twingoiiivin_battery_temperature', @@ -2770,6 +2909,7 @@ 'original_name': 'Charge state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1twingoiiivin_charge_state', @@ -2824,12 +2964,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging remaining time', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1twingoiiivin_charging_remaining_time', @@ -2880,6 +3024,7 @@ 'original_name': 'HVAC SoC threshold', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1twingoiiivin_hvac_soc_threshold', @@ -2928,6 +3073,7 @@ 'original_name': 'Last battery activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1twingoiiivin_battery_last_activity', @@ -2976,6 +3122,7 @@ 'original_name': 'Last HVAC activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1twingoiiivin_hvac_last_activity', @@ -3024,6 +3171,7 @@ 'original_name': 'Last location activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1twingoiiivin_location_last_activity', @@ -3068,12 +3216,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1twingoiiivin_mileage', @@ -3120,12 +3272,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1twingoiiivin_outside_temperature', @@ -3184,6 +3340,7 @@ 'original_name': 'Plug state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1twingoiiivin_plug_state', @@ -3241,6 +3398,7 @@ 'original_name': 'Battery', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe40vin_battery_level', @@ -3287,12 +3445,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery autonomy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1zoe40vin_battery_autonomy', @@ -3339,12 +3501,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1zoe40vin_battery_available_energy', @@ -3391,12 +3557,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1zoe40vin_battery_temperature', @@ -3458,6 +3628,7 @@ 'original_name': 'Charge state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1zoe40vin_charge_state', @@ -3512,12 +3683,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging power', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_power', 'unique_id': 'vf1zoe40vin_charging_power', @@ -3564,12 +3739,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging remaining time', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1zoe40vin_charging_remaining_time', @@ -3620,6 +3799,7 @@ 'original_name': 'HVAC SoC threshold', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1zoe40vin_hvac_soc_threshold', @@ -3668,6 +3848,7 @@ 'original_name': 'Last battery activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1zoe40vin_battery_last_activity', @@ -3716,6 +3897,7 @@ 'original_name': 'Last HVAC activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1zoe40vin_hvac_last_activity', @@ -3760,12 +3942,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1zoe40vin_mileage', @@ -3812,12 +3998,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1zoe40vin_outside_temperature', @@ -3876,6 +4066,7 @@ 'original_name': 'Plug state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1zoe40vin_plug_state', @@ -3927,12 +4118,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1zoe50vin_charging_power', @@ -3985,6 +4180,7 @@ 'original_name': 'Battery', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1zoe50vin_battery_level', @@ -4031,12 +4227,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery autonomy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1zoe50vin_battery_autonomy', @@ -4083,12 +4283,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1zoe50vin_battery_available_energy', @@ -4135,12 +4339,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1zoe50vin_battery_temperature', @@ -4202,6 +4410,7 @@ 'original_name': 'Charge state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1zoe50vin_charge_state', @@ -4256,12 +4465,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charging remaining time', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1zoe50vin_charging_remaining_time', @@ -4312,6 +4525,7 @@ 'original_name': 'HVAC SoC threshold', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1zoe50vin_hvac_soc_threshold', @@ -4360,6 +4574,7 @@ 'original_name': 'Last battery activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1zoe50vin_battery_last_activity', @@ -4408,6 +4623,7 @@ 'original_name': 'Last HVAC activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1zoe50vin_hvac_last_activity', @@ -4456,6 +4672,7 @@ 'original_name': 'Last location activity', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1zoe50vin_location_last_activity', @@ -4500,12 +4717,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1zoe50vin_mileage', @@ -4552,12 +4773,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1zoe50vin_outside_temperature', @@ -4616,6 +4841,7 @@ 'original_name': 'Plug state', 'platform': 'renault', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1zoe50vin_plug_state', diff --git a/tests/components/renault/test_diagnostics.py b/tests/components/renault/test_diagnostics.py index 233a32f7af8..1e238b15225 100644 --- a/tests/components/renault/test_diagnostics.py +++ b/tests/components/renault/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Renault diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.renault import DOMAIN from homeassistant.config_entries import ConfigEntry diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 1762210ec6f..eef38c00f36 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -8,7 +8,7 @@ import pytest from renault_api.exceptions import RenaultException from renault_api.kamereon import schemas from renault_api.kamereon.models import ChargeSchedule, HvacSchedule -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.renault.const import DOMAIN from homeassistant.components.renault.services import ( diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 3551632903f..86c4ed861a1 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -19,7 +19,12 @@ from homeassistant.components.reolink import ( FIRMWARE_UPDATE_INTERVAL, NUM_CRED_ERRORS, ) -from homeassistant.components.reolink.const import CONF_BC_PORT, DOMAIN +from homeassistant.components.reolink.const import ( + BATTERY_ALL_WAKE_UPDATE_INTERVAL, + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, + CONF_BC_PORT, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -1111,6 +1116,76 @@ async def test_privacy_mode_change_callback( assert config_entry.state is ConfigEntryState.NOT_LOADED +async def test_camera_wake_callback( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test camera wake callback.""" + + class callback_mock_class: + callback_func = None + + def register_callback( + self, callback_id: str, callback: Callable[[], None], *args, **key_args + ) -> None: + if callback_id == "camera_0_wake": + self.callback_func = callback + + callback_mock = callback_mock_class() + + reolink_connect.model = TEST_HOST_MODEL + reolink_connect.baichuan.events_active = True + reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_connect.baichuan.register_callback = callback_mock.register_callback + reolink_connect.sleeping.return_value = True + reolink_connect.audio_record.return_value = True + reolink_connect.get_states = AsyncMock() + + with ( + patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]), + patch( + "homeassistant.components.reolink.host.time", + return_value=BATTERY_ALL_WAKE_UPDATE_INTERVAL, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.sleeping.return_value = False + reolink_connect.get_states.reset_mock() + assert reolink_connect.get_states.call_count == 0 + + # simulate a TCP push callback signaling the battery camera woke up + reolink_connect.audio_record.return_value = False + assert callback_mock.callback_func is not None + with ( + patch( + "homeassistant.components.reolink.host.time", + return_value=BATTERY_ALL_WAKE_UPDATE_INTERVAL + + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL + + 5, + ), + patch( + "homeassistant.components.reolink.time", + return_value=BATTERY_ALL_WAKE_UPDATE_INTERVAL + + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL + + 5, + ), + ): + callback_mock.callback_func() + await hass.async_block_till_done() + + # check that a coordinator update was scheduled. + assert reolink_connect.get_states.call_count >= 1 + assert hass.states.get(entity_id).state == STATE_OFF + + async def test_remove( hass: HomeAssistant, reolink_connect: MagicMock, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 7044ea53671..126d445ca01 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.exceptions import ReolinkError +from reolink_aio.typings import VOD_trigger from homeassistant.components.media_source import ( DOMAIN as MEDIA_SOURCE_DOMAIN, @@ -16,6 +17,7 @@ from homeassistant.components.media_source import ( ) from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import CONF_BC_PORT, CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.media_source import VOD_SPLIT_TIME from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN from homeassistant.const import ( CONF_HOST, @@ -51,8 +53,12 @@ TEST_DAY = 14 TEST_DAY2 = 15 TEST_HOUR = 13 TEST_MINUTE = 12 -TEST_FILE_NAME = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00" -TEST_FILE_NAME_MP4 = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00.mp4" +TEST_START = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}" +TEST_END = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE + 5}" +TEST_START_TIME = datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, 0, 0) +TEST_END_TIME = datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, 23, 59, 59) +TEST_FILE_NAME = f"{TEST_START}00" +TEST_FILE_NAME_MP4 = f"{TEST_START}00.mp4" TEST_STREAM = "main" TEST_CHANNEL = "0" TEST_CAM_NAME = "Cam new name" @@ -92,17 +98,15 @@ async def test_resolve( await hass.async_block_till_done() caplog.set_level(logging.DEBUG) - file_id = ( - f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" - ) - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) + file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}|{TEST_START}|{TEST_END}" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None ) - assert play_media.mime_type == TEST_MIME_TYPE + assert play_media.mime_type == TEST_MIME_TYPE_MP4 - file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME_MP4}" + file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME_MP4}|{TEST_START}|{TEST_END}" reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL2) play_media = await async_resolve_media( @@ -117,9 +121,7 @@ async def test_resolve( ) assert play_media.mime_type == TEST_MIME_TYPE_MP4 - file_id = ( - f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" - ) + file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}|{TEST_START}|{TEST_END}" reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) play_media = await async_resolve_media( @@ -214,17 +216,18 @@ async def test_browsing( # browse camera recording files on day mock_vod_file = MagicMock() - mock_vod_file.start_time = datetime( - TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE - ) - mock_vod_file.duration = timedelta(minutes=15) + mock_vod_file.start_time = TEST_START_TIME + mock_vod_file.start_time_id = TEST_START + mock_vod_file.end_time_id = TEST_END + mock_vod_file.duration = timedelta(minutes=5) mock_vod_file.file_name = TEST_FILE_NAME + mock_vod_file.triggers = VOD_trigger.PERSON reolink_connect.request_vod_files.return_value = ([mock_status], [mock_vod_file]) browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") browse_files_id = f"FILES|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}" - browse_file_id = f"FILE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" + browse_file_id = f"FILE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}|{TEST_START}|{TEST_END}" assert browse.domain == DOMAIN assert ( browse.title @@ -232,9 +235,46 @@ async def test_browsing( ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id + reolink_connect.request_vod_files.assert_called_with( + int(TEST_CHANNEL), + TEST_START_TIME, + TEST_END_TIME, + stream=TEST_STREAM, + split_time=VOD_SPLIT_TIME, + trigger=None, + ) reolink_connect.model = TEST_HOST_MODEL + # browse event trigger person on a NVR + reolink_connect.is_nvr = True + browse_event_person_id = f"EVE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}|{VOD_trigger.PERSON.name}" + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") + assert browse.children[0].identifier == browse_event_person_id + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/{browse_event_person_id}" + ) + + assert browse.domain == DOMAIN + assert ( + browse.title + == f"{TEST_NVR_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY} Person" + ) + assert browse.identifier == browse_files_id + assert browse.children[0].identifier == browse_file_id + reolink_connect.request_vod_files.assert_called_with( + int(TEST_CHANNEL), + TEST_START_TIME, + TEST_END_TIME, + stream=TEST_STREAM, + split_time=VOD_SPLIT_TIME, + trigger=VOD_trigger.PERSON, + ) + + reolink_connect.is_nvr = False + async def test_browsing_h265_encoding( hass: HomeAssistant, diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index 3521de072b6..992e47f0575 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -58,17 +58,22 @@ def get_mock_session( return mock_session +@pytest.mark.parametrize( + ("content_type"), + [("video/mp4"), ("application/octet-stream"), ("apolication/octet-stream")], +) async def test_playback_proxy( hass: HomeAssistant, reolink_connect: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, + content_type: str, ) -> None: """Test successful playback proxy URL.""" reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) - mock_session = get_mock_session() + mock_session = get_mock_session(content_type=content_type) with patch( "homeassistant.components.reolink.views.async_get_clientsession", diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py index 1caae302748..d702cd44718 100644 --- a/tests/components/rflink/test_init.py +++ b/tests/components/rflink/test_init.py @@ -1,5 +1,6 @@ """Common functions for RFLink component tests and generic platform tests.""" +import logging from unittest.mock import Mock import pytest @@ -21,6 +22,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, + EVENT_LOGGING_CHANGED, SERVICE_STOP_COVER, SERVICE_TURN_OFF, ) @@ -556,3 +558,30 @@ async def test_unique_id( temperature_entry = entity_registry.async_get("sensor.temperature_device") assert temperature_entry assert temperature_entry.unique_id == "my_temperature_device_unique_id" + + +async def test_enable_debug_logs( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that changing debug level enables RFDEBUG.""" + + domain = RFLINK_DOMAIN + config = {RFLINK_DOMAIN: {CONF_HOST: "10.10.0.1", CONF_PORT: 1234}} + + # setup mocking rflink module + _, mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) + + logging.getLogger("rflink").setLevel(logging.DEBUG) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() + + assert "RFDEBUG enabled" in caplog.text + assert "RFDEBUG disabled" not in caplog.text + + logging.getLogger("rflink").setLevel(logging.INFO) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() + + assert "RFDEBUG disabled" in caplog.text diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index 45683bba903..bfdf7d8a9da 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Ridwell diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/ring/snapshots/test_binary_sensor.ambr b/tests/components/ring/snapshots/test_binary_sensor.ambr index 09dab9b0ecc..9fa57800ec9 100644 --- a/tests/components/ring/snapshots/test_binary_sensor.ambr +++ b/tests/components/ring/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Ding', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_door_ding', 'supported_features': 0, 'translation_key': 'ding', 'unique_id': '987654-ding', @@ -76,6 +77,7 @@ 'original_name': 'Motion', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_door_motion', 'supported_features': 0, 'translation_key': None, 'unique_id': '987654-motion', @@ -125,6 +127,7 @@ 'original_name': 'Motion', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_motion', 'supported_features': 0, 'translation_key': None, 'unique_id': '765432-motion', @@ -174,6 +177,7 @@ 'original_name': 'Ding', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'ingress_ding', 'supported_features': 0, 'translation_key': 'ding', 'unique_id': '185036587-ding', @@ -223,6 +227,7 @@ 'original_name': 'Motion', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'internal_motion', 'supported_features': 0, 'translation_key': None, 'unique_id': '345678-motion', diff --git a/tests/components/ring/snapshots/test_button.ambr b/tests/components/ring/snapshots/test_button.ambr index 7da11d66194..fe9afb7964e 100644 --- a/tests/components/ring/snapshots/test_button.ambr +++ b/tests/components/ring/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Open door', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'open_door', 'unique_id': '185036587-open_door', diff --git a/tests/components/ring/snapshots/test_camera.ambr b/tests/components/ring/snapshots/test_camera.ambr index 8c3b8a083b0..bc0ecbdc794 100644 --- a/tests/components/ring/snapshots/test_camera.ambr +++ b/tests/components/ring/snapshots/test_camera.ambr @@ -27,6 +27,7 @@ 'original_name': 'Last recording', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_recording', 'unique_id': '987654-last_recording', @@ -81,6 +82,7 @@ 'original_name': 'Live view', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'live_view', 'unique_id': '987654-live_view', @@ -94,7 +96,6 @@ 'attribution': 'Data provided by Ring.com', 'entity_picture': '/api/camera_proxy/camera.front_door_live_view?token=1caab5c3b3', 'friendly_name': 'Front Door Live view', - 'frontend_stream_type': , 'last_video_id': None, 'supported_features': , 'video_url': None, @@ -135,6 +136,7 @@ 'original_name': 'Last recording', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_recording', 'unique_id': '765432-last_recording', @@ -188,6 +190,7 @@ 'original_name': 'Live view', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'live_view', 'unique_id': '765432-live_view', @@ -201,7 +204,6 @@ 'attribution': 'Data provided by Ring.com', 'entity_picture': '/api/camera_proxy/camera.front_live_view?token=1caab5c3b3', 'friendly_name': 'Front Live view', - 'frontend_stream_type': , 'last_video_id': None, 'supported_features': , 'video_url': None, @@ -242,6 +244,7 @@ 'original_name': 'Last recording', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_recording', 'unique_id': '345678-last_recording', @@ -296,6 +299,7 @@ 'original_name': 'Live view', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'live_view', 'unique_id': '345678-live_view', @@ -309,7 +313,6 @@ 'attribution': 'Data provided by Ring.com', 'entity_picture': '/api/camera_proxy/camera.internal_live_view?token=1caab5c3b3', 'friendly_name': 'Internal Live view', - 'frontend_stream_type': , 'last_video_id': None, 'supported_features': , 'video_url': None, diff --git a/tests/components/ring/snapshots/test_event.ambr b/tests/components/ring/snapshots/test_event.ambr index 9c0fee906a0..f1d2d2fd09f 100644 --- a/tests/components/ring/snapshots/test_event.ambr +++ b/tests/components/ring/snapshots/test_event.ambr @@ -31,6 +31,7 @@ 'original_name': 'Ding', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ding', 'unique_id': '987654-ding', @@ -88,6 +89,7 @@ 'original_name': 'Motion', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion', 'unique_id': '987654-motion', @@ -145,6 +147,7 @@ 'original_name': 'Motion', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion', 'unique_id': '765432-motion', @@ -202,6 +205,7 @@ 'original_name': 'Ding', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ding', 'unique_id': '185036587-ding', @@ -259,6 +263,7 @@ 'original_name': 'Intercom unlock', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'intercom_unlock', 'unique_id': '185036587-intercom_unlock', @@ -316,6 +321,7 @@ 'original_name': 'Motion', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion', 'unique_id': '345678-motion', diff --git a/tests/components/ring/snapshots/test_light.ambr b/tests/components/ring/snapshots/test_light.ambr index 6c6effb93c1..8727adbb6e2 100644 --- a/tests/components/ring/snapshots/test_light.ambr +++ b/tests/components/ring/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': 'Light', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': '765432', @@ -88,6 +89,7 @@ 'original_name': 'Light', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': '345678', diff --git a/tests/components/ring/snapshots/test_number.ambr b/tests/components/ring/snapshots/test_number.ambr index abc63051f6a..b32a97f71d2 100644 --- a/tests/components/ring/snapshots/test_number.ambr +++ b/tests/components/ring/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '123456-volume', @@ -89,6 +90,7 @@ 'original_name': 'Volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '987654-volume', @@ -146,6 +148,7 @@ 'original_name': 'Volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '765432-volume', @@ -203,6 +206,7 @@ 'original_name': 'Doorbell volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'doorbell_volume', 'unique_id': '185036587-doorbell_volume', @@ -260,6 +264,7 @@ 'original_name': 'Mic volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mic_volume', 'unique_id': '185036587-mic_volume', @@ -317,6 +322,7 @@ 'original_name': 'Voice volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voice_volume', 'unique_id': '185036587-voice_volume', @@ -374,6 +380,7 @@ 'original_name': 'Volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '345678-volume', diff --git a/tests/components/ring/snapshots/test_sensor.ambr b/tests/components/ring/snapshots/test_sensor.ambr index 615bd1df018..249a47548b8 100644 --- a/tests/components/ring/snapshots/test_sensor.ambr +++ b/tests/components/ring/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'downstairs_volume', 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '123456-volume', @@ -75,6 +76,7 @@ 'original_name': 'Wi-Fi signal category', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'downstairs_wifi_signal_category', 'supported_features': 0, 'translation_key': 'wifi_signal_category', 'unique_id': '123456-wifi_signal_category', @@ -123,6 +125,7 @@ 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'downstairs_wifi_signal_strength', 'supported_features': 0, 'translation_key': None, 'unique_id': '123456-wifi_signal_strength', @@ -175,6 +178,7 @@ 'original_name': 'Battery', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '765432-battery', @@ -228,6 +232,7 @@ 'original_name': 'Battery', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '987654-battery', @@ -279,6 +284,7 @@ 'original_name': 'Last activity', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_activity', 'unique_id': '987654-last_activity', @@ -328,6 +334,7 @@ 'original_name': 'Last ding', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_door_last_ding', 'supported_features': 0, 'translation_key': 'last_ding', 'unique_id': '987654-last_ding', @@ -377,6 +384,7 @@ 'original_name': 'Last motion', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_door_last_motion', 'supported_features': 0, 'translation_key': 'last_motion', 'unique_id': '987654-last_motion', @@ -426,6 +434,7 @@ 'original_name': 'Volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_door_volume', 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '765432-volume', @@ -474,6 +483,7 @@ 'original_name': 'Wi-Fi signal category', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_door_wifi_signal_category', 'supported_features': 0, 'translation_key': 'wifi_signal_category', 'unique_id': '987654-wifi_signal_category', @@ -522,6 +532,7 @@ 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_door_wifi_signal_strength', 'supported_features': 0, 'translation_key': None, 'unique_id': '987654-wifi_signal_strength', @@ -572,6 +583,7 @@ 'original_name': 'Last activity', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_activity', 'unique_id': '765432-last_activity', @@ -621,6 +633,7 @@ 'original_name': 'Last ding', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_last_ding', 'supported_features': 0, 'translation_key': 'last_ding', 'unique_id': '765432-last_ding', @@ -670,6 +683,7 @@ 'original_name': 'Last motion', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_last_motion', 'supported_features': 0, 'translation_key': 'last_motion', 'unique_id': '765432-last_motion', @@ -719,6 +733,7 @@ 'original_name': 'Wi-Fi signal category', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_wifi_signal_category', 'supported_features': 0, 'translation_key': 'wifi_signal_category', 'unique_id': '765432-wifi_signal_category', @@ -767,6 +782,7 @@ 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_wifi_signal_strength', 'supported_features': 0, 'translation_key': None, 'unique_id': '765432-wifi_signal_strength', @@ -819,6 +835,7 @@ 'original_name': 'Battery', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '185036587-battery', @@ -870,6 +887,7 @@ 'original_name': 'Doorbell volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'ingress_doorbell_volume', 'supported_features': 0, 'translation_key': 'doorbell_volume', 'unique_id': '185036587-doorbell_volume', @@ -918,6 +936,7 @@ 'original_name': 'Last activity', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_activity', 'unique_id': '185036587-last_activity', @@ -967,6 +986,7 @@ 'original_name': 'Mic volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'ingress_mic_volume', 'supported_features': 0, 'translation_key': 'mic_volume', 'unique_id': '185036587-mic_volume', @@ -1015,6 +1035,7 @@ 'original_name': 'Voice volume', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'ingress_voice_volume', 'supported_features': 0, 'translation_key': 'voice_volume', 'unique_id': '185036587-voice_volume', @@ -1063,6 +1084,7 @@ 'original_name': 'Wi-Fi signal category', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'ingress_wifi_signal_category', 'supported_features': 0, 'translation_key': 'wifi_signal_category', 'unique_id': '185036587-wifi_signal_category', @@ -1111,6 +1133,7 @@ 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'ingress_wifi_signal_strength', 'supported_features': 0, 'translation_key': None, 'unique_id': '185036587-wifi_signal_strength', @@ -1163,6 +1186,7 @@ 'original_name': 'Battery', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '345678-battery', @@ -1214,6 +1238,7 @@ 'original_name': 'Last activity', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_activity', 'unique_id': '345678-last_activity', @@ -1263,6 +1288,7 @@ 'original_name': 'Last ding', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'internal_last_ding', 'supported_features': 0, 'translation_key': 'last_ding', 'unique_id': '345678-last_ding', @@ -1312,6 +1338,7 @@ 'original_name': 'Last motion', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'internal_last_motion', 'supported_features': 0, 'translation_key': 'last_motion', 'unique_id': '345678-last_motion', @@ -1361,6 +1388,7 @@ 'original_name': 'Wi-Fi signal category', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'internal_wifi_signal_category', 'supported_features': 0, 'translation_key': 'wifi_signal_category', 'unique_id': '345678-wifi_signal_category', @@ -1409,6 +1437,7 @@ 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'internal_wifi_signal_strength', 'supported_features': 0, 'translation_key': None, 'unique_id': '345678-wifi_signal_strength', diff --git a/tests/components/ring/snapshots/test_siren.ambr b/tests/components/ring/snapshots/test_siren.ambr index 8ef08815a1e..0c4ef24074a 100644 --- a/tests/components/ring/snapshots/test_siren.ambr +++ b/tests/components/ring/snapshots/test_siren.ambr @@ -32,6 +32,7 @@ 'original_name': 'Siren', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'siren', 'unique_id': '123456-siren', @@ -85,6 +86,7 @@ 'original_name': 'Siren', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'siren', 'unique_id': '765432', @@ -134,6 +136,7 @@ 'original_name': 'Siren', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'siren', 'unique_id': '345678', diff --git a/tests/components/ring/snapshots/test_switch.ambr b/tests/components/ring/snapshots/test_switch.ambr index 8c7c55d5169..69983644065 100644 --- a/tests/components/ring/snapshots/test_switch.ambr +++ b/tests/components/ring/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'In-home chime', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'in_home_chime', 'unique_id': '987654-in_home_chime', @@ -75,6 +76,7 @@ 'original_name': 'Motion detection', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_detection', 'unique_id': '987654-motion_detection', @@ -123,6 +125,7 @@ 'original_name': 'Motion detection', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_detection', 'unique_id': '765432-motion_detection', @@ -171,6 +174,7 @@ 'original_name': 'Siren', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'front_siren', 'supported_features': 0, 'translation_key': 'siren', 'unique_id': '765432-siren', @@ -219,6 +223,7 @@ 'original_name': 'Motion detection', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_detection', 'unique_id': '345678-motion_detection', @@ -267,6 +272,7 @@ 'original_name': 'Siren', 'platform': 'ring', 'previous_unique_id': None, + 'suggested_object_id': 'internal_siren', 'supported_features': 0, 'translation_key': 'siren', 'unique_id': '345678-siren', diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index d807e35710b..f95e4795d1d 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -72,7 +72,7 @@ def bypass_api_client_fixture() -> None: """Skip calls to the API client.""" with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", return_value=HOME_DATA, ), patch( @@ -183,7 +183,7 @@ def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: home_data_copy = deepcopy(HOME_DATA) home_data_copy.received_devices = [] with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", return_value=home_data_copy, ): yield diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index a1bcfc462e4..01a8aa26de7 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -54,7 +54,7 @@ async def test_config_entry_not_ready( """Test that when coordinator update fails, entry retries.""" with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", @@ -71,7 +71,7 @@ async def test_config_entry_not_ready_home_data( """Test that when we fail to get home data, entry retries.""" with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", side_effect=RoborockException(), ), patch( @@ -164,7 +164,7 @@ async def test_reauth_started( ) -> None: """Test reauth flow started.""" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", side_effect=RoborockInvalidCredentials(), ): await async_setup_component(hass, DOMAIN, {}) @@ -249,7 +249,7 @@ async def test_not_supported_protocol( home_data_copy = deepcopy(HOME_DATA) home_data_copy.received_devices[0].pv = "random" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", return_value=home_data_copy, ): await hass.config_entries.async_setup(mock_roborock_entry.entry_id) @@ -267,7 +267,7 @@ async def test_not_supported_a01_device( home_data_copy = deepcopy(HOME_DATA) home_data_copy.products[2].category = "random" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", return_value=home_data_copy, ): await async_setup_component(hass, DOMAIN, {}) @@ -282,7 +282,7 @@ async def test_invalid_user_agreement( ) -> None: """Test that we fail setting up if the user agreement is out of date.""" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", side_effect=RoborockInvalidUserAgreement(), ): await hass.config_entries.async_setup(mock_roborock_entry.entry_id) @@ -299,7 +299,7 @@ async def test_no_user_agreement( ) -> None: """Test that we fail setting up if the user has no agreement.""" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", side_effect=RoborockNoUserAgreement(), ): await hass.config_entries.async_setup(mock_roborock_entry.entry_id) @@ -330,7 +330,7 @@ async def test_stale_device( with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", return_value=hd, ), patch( diff --git a/tests/components/roku/test_diagnostics.py b/tests/components/roku/test_diagnostics.py index 37e0d43a582..c352fa60b56 100644 --- a/tests/components/roku/test_diagnostics.py +++ b/tests/components/roku/test_diagnostics.py @@ -1,7 +1,7 @@ """Tests for the diagnostics data provided by the Roku integration.""" from rokuecp import Device as RokuDevice -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util diff --git a/tests/components/rova/snapshots/test_sensor.ambr b/tests/components/rova/snapshots/test_sensor.ambr index 90cf29a1b89..7d3cb7c5962 100644 --- a/tests/components/rova/snapshots/test_sensor.ambr +++ b/tests/components/rova/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Bio', 'platform': 'rova', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bio', 'unique_id': '8381BE13_gft', @@ -75,6 +76,7 @@ 'original_name': 'Paper', 'platform': 'rova', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'paper', 'unique_id': '8381BE13_papier', @@ -123,6 +125,7 @@ 'original_name': 'Plastic', 'platform': 'rova', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'plastic', 'unique_id': '8381BE13_pmd', @@ -171,6 +174,7 @@ 'original_name': 'Residual', 'platform': 'rova', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'residual', 'unique_id': '8381BE13_restafval', diff --git a/tests/components/rova/test_init.py b/tests/components/rova/test_init.py index 2190e2f8ce3..5441a730bf6 100644 --- a/tests/components/rova/test_init.py +++ b/tests/components/rova/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock import pytest from requests import ConnectTimeout -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rova import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/rova/test_sensor.py b/tests/components/rova/test_sensor.py index ae8b64363da..27a3c109ce3 100644 --- a/tests/components/rova/test_sensor.py +++ b/tests/components/rova/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/rtsp_to_webrtc/__init__.py b/tests/components/rtsp_to_webrtc/__init__.py deleted file mode 100644 index ee4206e357d..00000000000 --- a/tests/components/rtsp_to_webrtc/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the RTSPtoWebRTC integration.""" diff --git a/tests/components/rtsp_to_webrtc/conftest.py b/tests/components/rtsp_to_webrtc/conftest.py deleted file mode 100644 index 956825f6372..00000000000 --- a/tests/components/rtsp_to_webrtc/conftest.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Tests for RTSPtoWebRTC initialization.""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator, Awaitable, Callable -from typing import Any -from unittest.mock import patch - -import pytest -import rtsp_to_webrtc - -from homeassistant.components import camera -from homeassistant.components.rtsp_to_webrtc import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -STREAM_SOURCE = "rtsp://example.com" -SERVER_URL = "http://127.0.0.1:8083" - -CONFIG_ENTRY_DATA = {"server_url": SERVER_URL} - -# Typing helpers -type ComponentSetup = Callable[[], Awaitable[None]] -type AsyncYieldFixture[_T] = AsyncGenerator[_T] - - -@pytest.fixture(autouse=True) -async def webrtc_server() -> None: - """Patch client library to force usage of RTSPtoWebRTC server.""" - with patch( - "rtsp_to_webrtc.client.WebClient.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ResponseError(), - ): - yield - - -@pytest.fixture -async def mock_camera(hass: HomeAssistant) -> AsyncGenerator[None]: - """Initialize a demo camera platform.""" - assert await async_setup_component( - hass, "camera", {camera.DOMAIN: {"platform": "demo"}} - ) - await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.demo.camera.Path.read_bytes", - return_value=b"Test", - ), - patch( - "homeassistant.components.camera.Camera.stream_source", - return_value=STREAM_SOURCE, - ), - ): - yield - - -@pytest.fixture -async def config_entry_data() -> dict[str, Any]: - """Fixture for MockConfigEntry data.""" - return CONFIG_ENTRY_DATA - - -@pytest.fixture -def config_entry_options() -> dict[str, Any] | None: - """Fixture to set initial config entry options.""" - return None - - -@pytest.fixture -async def config_entry( - config_entry_data: dict[str, Any], - config_entry_options: dict[str, Any] | None, -) -> MockConfigEntry: - """Fixture for MockConfigEntry.""" - return MockConfigEntry( - domain=DOMAIN, data=config_entry_data, options=config_entry_options - ) - - -@pytest.fixture -async def rtsp_to_webrtc_client() -> None: - """Fixture for mock rtsp_to_webrtc client.""" - with patch("rtsp_to_webrtc.client.Client.heartbeat"): - yield - - -@pytest.fixture -async def setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> AsyncYieldFixture[ComponentSetup]: - """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) - - async def func() -> None: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - yield func - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - await hass.config_entries.async_unload(entries[0].entry_id) - await hass.async_block_till_done() - - assert not hass.data.get(DOMAIN) - assert entries[0].state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/rtsp_to_webrtc/test_config_flow.py b/tests/components/rtsp_to_webrtc/test_config_flow.py deleted file mode 100644 index d3afa80b0b4..00000000000 --- a/tests/components/rtsp_to_webrtc/test_config_flow.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Test the RTSPtoWebRTC config flow.""" - -from __future__ import annotations - -from unittest.mock import patch - -import rtsp_to_webrtc - -from homeassistant import config_entries -from homeassistant.components.rtsp_to_webrtc import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.service_info.hassio import HassioServiceInfo - -from .conftest import ComponentSetup - -from tests.common import MockConfigEntry - - -async def test_web_full_flow(hass: HomeAssistant) -> None: - """Check full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("data_schema").schema.get("server_url") is str - assert not result.get("errors") - with ( - patch("rtsp_to_webrtc.client.Client.heartbeat"), - patch( - "homeassistant.components.rtsp_to_webrtc.async_setup_entry", - return_value=True, - ) as mock_setup, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"server_url": "https://example.com"} - ) - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "https://example.com" - assert "result" in result - assert result["result"].data == {"server_url": "https://example.com"} - - assert len(mock_setup.mock_calls) == 1 - - -async def test_single_config_entry(hass: HomeAssistant) -> None: - """Test that only a single config entry is allowed.""" - old_entry = MockConfigEntry(domain=DOMAIN, data={"example": True}) - old_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "single_instance_allowed" - - -async def test_invalid_url(hass: HomeAssistant) -> None: - """Check full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("data_schema").schema.get("server_url") is str - assert not result.get("errors") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"server_url": "not-a-url"} - ) - - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"server_url": "invalid_url"} - - -async def test_server_unreachable(hass: HomeAssistant) -> None: - """Exercise case where the server is unreachable.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert not result.get("errors") - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ClientError(), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"server_url": "https://example.com"} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"base": "server_unreachable"} - - -async def test_server_failure(hass: HomeAssistant) -> None: - """Exercise case where server returns a failure.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert not result.get("errors") - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ResponseError(), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"server_url": "https://example.com"} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"base": "server_failure"} - - -async def test_hassio_discovery(hass: HomeAssistant) -> None: - """Test supervisor add-on discovery.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=HassioServiceInfo( - config={ - "addon": "RTSPtoWebRTC", - "host": "fake-server", - "port": 8083, - }, - name="RTSPtoWebRTC", - slug="rtsp-to-webrtc", - uuid="1234", - ), - context={"source": config_entries.SOURCE_HASSIO}, - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "hassio_confirm" - assert result.get("description_placeholders") == {"addon": "RTSPtoWebRTC"} - - with ( - patch("rtsp_to_webrtc.client.Client.heartbeat"), - patch( - "homeassistant.components.rtsp_to_webrtc.async_setup_entry", - return_value=True, - ) as mock_setup, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() - - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "RTSPtoWebRTC" - assert "result" in result - assert result["result"].data == {"server_url": "http://fake-server:8083"} - - assert len(mock_setup.mock_calls) == 1 - - -async def test_hassio_single_config_entry(hass: HomeAssistant) -> None: - """Test supervisor add-on discovery only allows a single entry.""" - old_entry = MockConfigEntry(domain=DOMAIN, data={"example": True}) - old_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=HassioServiceInfo( - config={ - "addon": "RTSPtoWebRTC", - "host": "fake-server", - "port": 8083, - }, - name="RTSPtoWebRTC", - slug="rtsp-to-webrtc", - uuid="1234", - ), - context={"source": config_entries.SOURCE_HASSIO}, - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "single_instance_allowed" - - -async def test_hassio_ignored(hass: HomeAssistant) -> None: - """Test ignoring superversor add-on discovery.""" - old_entry = MockConfigEntry(domain=DOMAIN, source=config_entries.SOURCE_IGNORE) - old_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=HassioServiceInfo( - config={ - "addon": "RTSPtoWebRTC", - "host": "fake-server", - "port": 8083, - }, - name="RTSPtoWebRTC", - slug="rtsp-to-webrtc", - uuid="1234", - ), - context={"source": config_entries.SOURCE_HASSIO}, - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "single_instance_allowed" - - -async def test_hassio_discovery_server_failure(hass: HomeAssistant) -> None: - """Test server failure during supvervisor add-on discovery shows an error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=HassioServiceInfo( - config={ - "addon": "RTSPtoWebRTC", - "host": "fake-server", - "port": 8083, - }, - name="RTSPtoWebRTC", - slug="rtsp-to-webrtc", - uuid="1234", - ), - context={"source": config_entries.SOURCE_HASSIO}, - ) - - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "hassio_confirm" - assert not result.get("errors") - - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ResponseError(), - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "server_failure" - - -async def test_options_flow( - hass: HomeAssistant, - config_entry: MockConfigEntry, - setup_integration: ComponentSetup, -) -> None: - """Test setting stun server in options flow.""" - with patch( - "homeassistant.components.rtsp_to_webrtc.async_setup_entry", - return_value=True, - ): - await setup_integration() - - assert config_entry.state is ConfigEntryState.LOADED - assert not config_entry.options - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - data_schema = result["data_schema"].schema - assert set(data_schema) == {"stun_server"} - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "stun_server": "example.com:1234", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - assert config_entry.options == {"stun_server": "example.com:1234"} - - # Clear the value - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - assert config_entry.options == {} diff --git a/tests/components/rtsp_to_webrtc/test_diagnostics.py b/tests/components/rtsp_to_webrtc/test_diagnostics.py deleted file mode 100644 index ad3522686b6..00000000000 --- a/tests/components/rtsp_to_webrtc/test_diagnostics.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Test nest diagnostics.""" - -from typing import Any - -from homeassistant.core import HomeAssistant - -from .conftest import ComponentSetup - -from tests.common import MockConfigEntry -from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.typing import ClientSessionGenerator - -THERMOSTAT_TYPE = "sdm.devices.types.THERMOSTAT" - - -async def test_entry_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - config_entry: MockConfigEntry, - rtsp_to_webrtc_client: Any, - setup_integration: ComponentSetup, -) -> None: - """Test config entry diagnostics.""" - await setup_integration() - - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert "webrtc" in result diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py deleted file mode 100644 index 985e76fa1d1..00000000000 --- a/tests/components/rtsp_to_webrtc/test_init.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Tests for RTSPtoWebRTC initialization.""" - -from __future__ import annotations - -import base64 -from typing import Any -from unittest.mock import patch - -import aiohttp -import pytest -import rtsp_to_webrtc - -from homeassistant.components.rtsp_to_webrtc import DOMAIN -from homeassistant.components.websocket_api import TYPE_RESULT -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component - -from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import WebSocketGenerator - -# The webrtc component does not inspect the details of the offer and answer, -# and is only a pass through. -OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." -ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..." - - -@pytest.fixture(autouse=True) -async def setup_homeassistant(hass: HomeAssistant): - """Set up the homeassistant integration.""" - await async_setup_component(hass, "homeassistant", {}) - - -@pytest.mark.usefixtures("rtsp_to_webrtc_client") -async def test_setup_success( - hass: HomeAssistant, - config_entry: MockConfigEntry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test successful setup and unload.""" - config_entry.add_to_hass(hass) - - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert issue_registry.async_get_issue(DOMAIN, "deprecated") - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - await hass.config_entries.async_unload(entries[0].entry_id) - await hass.async_block_till_done() - - assert not hass.data.get(DOMAIN) - assert entries[0].state is ConfigEntryState.NOT_LOADED - assert not issue_registry.async_get_issue(DOMAIN, "deprecated") - - -@pytest.mark.parametrize("config_entry_data", [{}]) -async def test_invalid_config_entry( - hass: HomeAssistant, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup -) -> None: - """Test a config entry with missing required fields.""" - await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_ERROR - - -async def test_setup_server_failure( - hass: HomeAssistant, setup_integration: ComponentSetup -) -> None: - """Test server responds with a failure on startup.""" - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ResponseError(), - ): - await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_communication_failure( - hass: HomeAssistant, setup_integration: ComponentSetup -) -> None: - """Test unable to talk to server on startup.""" - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ClientError(), - ): - await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_RETRY - - -@pytest.mark.usefixtures("mock_camera", "rtsp_to_webrtc_client") -async def test_offer_for_stream_source( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - hass_ws_client: WebSocketGenerator, - setup_integration: ComponentSetup, -) -> None: - """Test successful response from RTSPtoWebRTC server.""" - await setup_integration() - - aioclient_mock.post( - f"{SERVER_URL}/stream", - json={"sdp64": base64.b64encode(ANSWER_SDP.encode("utf-8")).decode("utf-8")}, - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": OFFER_SDP, - } - ) - - response = await client.receive_json() - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "answer", - "answer": ANSWER_SDP, - } - - # Validate request parameters were sent correctly - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][2] == { - "sdp64": base64.b64encode(OFFER_SDP.encode("utf-8")).decode("utf-8"), - "url": STREAM_SOURCE, - } - - -@pytest.mark.usefixtures("mock_camera", "rtsp_to_webrtc_client") -async def test_offer_failure( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - hass_ws_client: WebSocketGenerator, - setup_integration: ComponentSetup, -) -> None: - """Test a transient failure talking to RTSPtoWebRTC server.""" - await setup_integration() - - aioclient_mock.post( - f"{SERVER_URL}/stream", - exc=aiohttp.ClientError, - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": OFFER_SDP, - } - ) - - response = await client.receive_json() - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "error", - "code": "webrtc_offer_failed", - "message": "RTSPtoWebRTC server communication failure: ", - } diff --git a/tests/components/russound_rio/test_diagnostics.py b/tests/components/russound_rio/test_diagnostics.py index c6c5441128d..3d83ef12df1 100644 --- a/tests/components/russound_rio/test_diagnostics.py +++ b/tests/components/russound_rio/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/russound_rio/test_init.py b/tests/components/russound_rio/test_init.py index d654eea32bd..935b921b069 100644 --- a/tests/components/russound_rio/test_init.py +++ b/tests/components/russound_rio/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, Mock from aiorussound.models import CallbackType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.russound_rio.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py index 6fa3d14e880..4d85055bb58 100644 --- a/tests/components/sabnzbd/conftest.py +++ b/tests/components/sabnzbd/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.sabnzbd import DOMAIN +from homeassistant.components.sabnzbd.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr index 1feaece1c3e..7da52a1acd7 100644 --- a/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr +++ b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Warnings', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'warnings', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_warnings', diff --git a/tests/components/sabnzbd/snapshots/test_button.ambr b/tests/components/sabnzbd/snapshots/test_button.ambr index f09bb44e8e4..60970ef6abd 100644 --- a/tests/components/sabnzbd/snapshots/test_button.ambr +++ b/tests/components/sabnzbd/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Pause', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pause', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_pause', @@ -74,6 +75,7 @@ 'original_name': 'Resume', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'resume', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_resume', diff --git a/tests/components/sabnzbd/snapshots/test_number.ambr b/tests/components/sabnzbd/snapshots/test_number.ambr index 623002470b7..8fb7b0d79db 100644 --- a/tests/components/sabnzbd/snapshots/test_number.ambr +++ b/tests/components/sabnzbd/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Speedlimit', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'speedlimit', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_speedlimit', diff --git a/tests/components/sabnzbd/snapshots/test_sensor.ambr b/tests/components/sabnzbd/snapshots/test_sensor.ambr index 893d270a569..3494899990c 100644 --- a/tests/components/sabnzbd/snapshots/test_sensor.ambr +++ b/tests/components/sabnzbd/snapshots/test_sensor.ambr @@ -32,6 +32,7 @@ 'original_name': 'Daily total', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_total', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_day_size', @@ -78,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Free disk space', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'free_disk_space', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_diskspace1', @@ -130,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Left to download', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'left', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_mbleft', @@ -191,6 +200,7 @@ 'original_name': 'Monthly total', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monthly_total', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_month_size', @@ -246,6 +256,7 @@ 'original_name': 'Overall total', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'overall_total', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_total_size', @@ -292,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Queue', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'queue', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_mb', @@ -353,6 +368,7 @@ 'original_name': 'Queue count', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'queue_count', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_noofslots_total', @@ -409,6 +425,7 @@ 'original_name': 'Speed', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'speed', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_kbpersec', @@ -459,6 +476,7 @@ 'original_name': 'Status', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_status', @@ -502,12 +520,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total disk space', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_disk_space', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_diskspacetotal1', @@ -563,6 +585,7 @@ 'original_name': 'Weekly total', 'platform': 'sabnzbd', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'weekly_total', 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_week_size', diff --git a/tests/components/sabnzbd/test_binary_sensor.py b/tests/components/sabnzbd/test_binary_sensor.py index 48a3c006488..e823ae6ba96 100644 --- a/tests/components/sabnzbd/test_binary_sensor.py +++ b/tests/components/sabnzbd/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/sabnzbd/test_button.py b/tests/components/sabnzbd/test_button.py index 199d8eb03a0..813d532a38b 100644 --- a/tests/components/sabnzbd/test_button.py +++ b/tests/components/sabnzbd/test_button.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from pysabnzbd import SabnzbdApiException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ( diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index 797af63c096..ec9044f4223 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -6,7 +6,7 @@ from pysabnzbd import SabnzbdApiException import pytest from homeassistant import config_entries -from homeassistant.components.sabnzbd import DOMAIN +from homeassistant.components.sabnzbd.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant @@ -153,10 +153,10 @@ async def test_abort_already_configured( assert result["reason"] == "already_configured" -async def test_abort_reconfigure_already_configured( +async def test_abort_reconfigure_successful( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Test that the reconfigure flow aborts if SABnzbd instance is already configured.""" + """Test that the reconfigure flow aborts successfully if SABnzbd instance is already configured.""" result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -166,4 +166,4 @@ async def test_abort_reconfigure_already_configured( VALID_CONFIG, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/sabnzbd/test_init.py b/tests/components/sabnzbd/test_init.py deleted file mode 100644 index 9b833875bbc..00000000000 --- a/tests/components/sabnzbd/test_init.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Tests for the SABnzbd Integration.""" - -import pytest - -from homeassistant.components.sabnzbd.const import ( - ATTR_API_KEY, - DOMAIN, - SERVICE_PAUSE, - SERVICE_RESUME, - SERVICE_SET_SPEED, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - - -@pytest.mark.parametrize( - ("service", "issue_id"), - [ - (SERVICE_RESUME, "resume_action_deprecated"), - (SERVICE_PAUSE, "pause_action_deprecated"), - (SERVICE_SET_SPEED, "set_speed_action_deprecated"), - ], -) -@pytest.mark.usefixtures("setup_integration") -async def test_deprecated_service_creates_issue( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - service: str, - issue_id: str, -) -> None: - """Test that deprecated actions creates an issue.""" - await hass.services.async_call( - DOMAIN, - service, - {ATTR_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0"}, - blocking=True, - ) - - issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) - assert issue - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.breaks_in_ha_version == "2025.6" diff --git a/tests/components/sabnzbd/test_number.py b/tests/components/sabnzbd/test_number.py index 61f7ea45ab1..974c5435f15 100644 --- a/tests/components/sabnzbd/test_number.py +++ b/tests/components/sabnzbd/test_number.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from pysabnzbd import SabnzbdApiException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/sabnzbd/test_sensor.py b/tests/components/sabnzbd/test_sensor.py index 31c0868a5a7..1e5e41efce0 100644 --- a/tests/components/sabnzbd/test_sensor.py +++ b/tests/components/sabnzbd/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py index 182ea850b52..54b23f45efe 100644 --- a/tests/components/samsungtv/__init__.py +++ b/tests/components/samsungtv/__init__.py @@ -5,8 +5,9 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from homeassistant.components.samsungtv.const import DOMAIN +from homeassistant.components.samsungtv.const import DOMAIN, METHOD_LEGACY from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_METHOD from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -20,7 +21,11 @@ async def setup_samsungtv_entry( domain=DOMAIN, data=data, entry_id="123456", - unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", + unique_id=( + None + if data[CONF_METHOD] == METHOD_LEGACY + else "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 4b3ad59defd..6fe784addd7 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -19,9 +19,11 @@ from samsungtvws.event import ED_INSTALLED_APP_EVENT from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand -from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT +from homeassistant.components.samsungtv.const import DOMAIN, WEBSOCKET_SSL_PORT -from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI +from .const import SAMPLE_DEVICE_INFO_WIFI + +from tests.common import load_json_object_fixture @pytest.fixture @@ -65,7 +67,7 @@ def fake_host_fixture() -> Generator[None]: """Patch gethostbyname.""" with patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", - return_value="fake_host", + return_value="10.20.43.21", ): yield @@ -146,15 +148,16 @@ def upnp_notify_server_fixture(upnp_factory: Mock) -> Generator[Mock]: yield notify_server -@pytest.fixture(name="remote") -def remote_fixture() -> Generator[Mock]: +@pytest.fixture(name="remote_legacy") +def remote_legacy_fixture() -> Generator[Mock]: """Patch the samsungctl Remote.""" - with patch("homeassistant.components.samsungtv.bridge.Remote") as remote_class: - remote = Mock(Remote) - remote.__enter__ = Mock() - remote.__exit__ = Mock() - remote_class.return_value = remote - yield remote + remote_legacy = Mock(Remote) + remote_legacy.__enter__ = Mock() + remote_legacy.__exit__ = Mock() + with patch( + "homeassistant.components.samsungtv.bridge.Remote", return_value=remote_legacy + ): + yield remote_legacy @pytest.fixture(name="rest_api") @@ -186,7 +189,7 @@ def rest_api_fixture_non_ssl_only() -> Generator[None]: """Mock rest_device_info to fail for ssl and work for non-ssl.""" if self.port == WEBSOCKET_SSL_PORT: raise ResponseError - return SAMPLE_DEVICE_INFO_UE48JU6400 + return load_json_object_fixture("device_info_UE48JU6400.json", DOMAIN) with patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", @@ -206,8 +209,8 @@ def rest_api_failure_fixture() -> Generator[None]: yield -@pytest.fixture(name="remoteencws_failing") -def remoteencws_failing_fixture() -> Generator[None]: +@pytest.fixture(name="remote_encrypted_websocket_failing") +def remote_encrypted_websocket_failing_fixture() -> Generator[None]: """Patch the samsungtvws SamsungTVEncryptedWSAsyncRemote.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", @@ -216,71 +219,77 @@ def remoteencws_failing_fixture() -> Generator[None]: yield -@pytest.fixture(name="remotews") -def remotews_fixture() -> Generator[Mock]: +@pytest.fixture(name="remote_websocket") +def remote_websocket_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVWS.""" - remotews = Mock(SamsungTVWSAsyncRemote) - remotews.__aenter__ = AsyncMock(return_value=remotews) - remotews.__aexit__ = AsyncMock() - remotews.token = "FAKE_TOKEN" - remotews.app_list_data = None + remote_websocket = Mock(SamsungTVWSAsyncRemote) + remote_websocket.__aenter__ = AsyncMock(return_value=remote_websocket) + remote_websocket.__aexit__ = AsyncMock() + remote_websocket.token = "FAKE_TOKEN" + remote_websocket.app_list_data = None async def _start_listening( ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): - remotews.ws_event_callback = ws_event_callback + remote_websocket.ws_event_callback = ws_event_callback async def _send_commands(commands: list[SamsungTVCommand]): if ( len(commands) == 1 and isinstance(commands[0], ChannelEmitCommand) and commands[0].params["event"] == "ed.installedApp.get" - and remotews.app_list_data is not None + and remote_websocket.app_list_data is not None ): - remotews.raise_mock_ws_event_callback( + remote_websocket.raise_mock_ws_event_callback( ED_INSTALLED_APP_EVENT, - remotews.app_list_data, + remote_websocket.app_list_data, ) def _mock_ws_event_callback(event: str, response: Any): - if remotews.ws_event_callback: - remotews.ws_event_callback(event, response) + if remote_websocket.ws_event_callback: + remote_websocket.ws_event_callback(event, response) - remotews.start_listening.side_effect = _start_listening - remotews.send_commands.side_effect = _send_commands - remotews.raise_mock_ws_event_callback = Mock(side_effect=_mock_ws_event_callback) + remote_websocket.start_listening.side_effect = _start_listening + remote_websocket.send_commands.side_effect = _send_commands + remote_websocket.raise_mock_ws_event_callback = Mock( + side_effect=_mock_ws_event_callback + ) with patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote", - ) as remotews_class: - remotews_class.return_value = remotews - yield remotews + return_value=remote_websocket, + ): + yield remote_websocket -@pytest.fixture(name="remoteencws") -def remoteencws_fixture() -> Generator[Mock]: +@pytest.fixture(name="remote_encrypted_websocket") +def remote_encrypted_websocket_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVEncryptedWSAsyncRemote.""" - remoteencws = Mock(SamsungTVEncryptedWSAsyncRemote) - remoteencws.__aenter__ = AsyncMock(return_value=remoteencws) - remoteencws.__aexit__ = AsyncMock() + remote_encrypted_websocket = Mock(SamsungTVEncryptedWSAsyncRemote) + remote_encrypted_websocket.__aenter__ = AsyncMock( + return_value=remote_encrypted_websocket + ) + remote_encrypted_websocket.__aexit__ = AsyncMock() def _start_listening( ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): - remoteencws.ws_event_callback = ws_event_callback + remote_encrypted_websocket.ws_event_callback = ws_event_callback def _mock_ws_event_callback(event: str, response: Any): - if remoteencws.ws_event_callback: - remoteencws.ws_event_callback(event, response) + if remote_encrypted_websocket.ws_event_callback: + remote_encrypted_websocket.ws_event_callback(event, response) - remoteencws.start_listening.side_effect = _start_listening - remoteencws.raise_mock_ws_event_callback = Mock(side_effect=_mock_ws_event_callback) + remote_encrypted_websocket.start_listening.side_effect = _start_listening + remote_encrypted_websocket.raise_mock_ws_event_callback = Mock( + side_effect=_mock_ws_event_callback + ) with patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote", ) as remotews_class: - remotews_class.return_value = remoteencws - yield remoteencws + remotews_class.return_value = remote_encrypted_websocket + yield remote_encrypted_websocket @pytest.fixture(name="mac_address", autouse=True) diff --git a/tests/components/samsungtv/const.py b/tests/components/samsungtv/const.py index c1a9da4e284..16ffb6b9c05 100644 --- a/tests/components/samsungtv/const.py +++ b/tests/components/samsungtv/const.py @@ -1,88 +1,49 @@ """Constants for the samsungtv tests.""" -from samsungtvws.event import ED_INSTALLED_APP_EVENT - from homeassistant.components.samsungtv.const import ( CONF_SESSION_ID, + DOMAIN, + ENCRYPTED_WEBSOCKET_PORT, + LEGACY_PORT, + METHOD_ENCRYPTED_WEBSOCKET, METHOD_LEGACY, METHOD_WEBSOCKET, + WEBSOCKET_SSL_PORT, ) from homeassistant.const import ( CONF_HOST, - CONF_IP_ADDRESS, CONF_MAC, CONF_METHOD, CONF_MODEL, - CONF_NAME, CONF_PORT, CONF_TOKEN, ) -from homeassistant.helpers.service_info.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, - SsdpServiceInfo, -) +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo -MOCK_CONFIG = { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 55000, +from tests.common import load_json_object_fixture + +ENTRYDATA_LEGACY = { + CONF_HOST: "10.10.12.34", + CONF_PORT: LEGACY_PORT, CONF_METHOD: METHOD_LEGACY, } -MOCK_CONFIG_ENCRYPTED_WS = { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 8000, -} -MOCK_ENTRYDATA_ENCRYPTED_WS = { - **MOCK_CONFIG_ENCRYPTED_WS, - CONF_IP_ADDRESS: "test", - CONF_METHOD: "encrypted", +ENTRYDATA_ENCRYPTED_WEBSOCKET = { + CONF_HOST: "10.10.12.34", + CONF_PORT: ENCRYPTED_WEBSOCKET_PORT, + CONF_METHOD: METHOD_ENCRYPTED_WEBSOCKET, CONF_MAC: "aa:bb:cc:dd:ee:ff", CONF_TOKEN: "037739871315caef138547b03e348b72", CONF_SESSION_ID: "2", } -MOCK_ENTRYDATA_WS = { - CONF_HOST: "fake_host", +ENTRYDATA_WEBSOCKET = { + CONF_HOST: "10.10.12.34", CONF_METHOD: METHOD_WEBSOCKET, - CONF_PORT: 8002, - CONF_MODEL: "any", - CONF_NAME: "any", -} -MOCK_ENTRY_WS_WITH_MAC = { - CONF_IP_ADDRESS: "test", - CONF_HOST: "fake_host", - CONF_METHOD: "websocket", + CONF_PORT: WEBSOCKET_SSL_PORT, CONF_MAC: "aa:bb:cc:dd:ee:ff", - CONF_NAME: "fake", - CONF_PORT: 8002, + CONF_MODEL: "UE43LS003", CONF_TOKEN: "123456789", } -MOCK_SSDP_DATA_RENDERING_CONTROL_ST = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="urn:schemas-upnp-org:service:RenderingControl:1", - ssdp_location="https://fake_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", - }, -) -MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="urn:samsung.com:service:MainTVAgent2:1", - ssdp_location="https://fake_host:12345/tv_agent", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", - }, -) SAMPLE_DEVICE_INFO_WIFI = { "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", @@ -95,101 +56,14 @@ SAMPLE_DEVICE_INFO_WIFI = { }, } -SAMPLE_DEVICE_INFO_FRAME = { - "device": { - "FrameTVSupport": "true", - "GamePadSupport": "true", - "ImeSyncedSupport": "true", - "OS": "Tizen", - "TokenAuthSupport": "true", - "VoiceSupport": "true", - "countryCode": "FR", - "description": "Samsung DTV RCR", - "developerIP": "0.0.0.0", - "developerMode": "0", - "duid": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", - "firmwareVersion": "Unknown", - "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", - "ip": "1.2.3.4", - "model": "17_KANTM_UHD", - "modelName": "UE43LS003", - "name": "[TV] Samsung Frame (43)", - "networkType": "wired", - "resolution": "3840x2160", - "smartHubAgreement": "true", - "type": "Samsung SmartTV", - "udn": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", - "wifiMac": "aa:ee:tt:hh:ee:rr", - }, - "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", - "isSupport": ( - '{"DMP_DRM_PLAYREADY":"false","DMP_DRM_WIDEVINE":"false","DMP_available":"true",' - '"EDEN_available":"true","FrameTVSupport":"true","ImeSyncedSupport":"true",' - '"TokenAuthSupport":"true","remote_available":"true","remote_fourDirections":"true",' - '"remote_touchPad":"true","remote_voiceControl":"true"}\n' - ), - "name": "[TV] Samsung Frame (43)", - "remote": "1.0", - "type": "Samsung SmartTV", - "uri": "https://1.2.3.4:8002/api/v2/", - "version": "2.0.25", -} +MOCK_SSDP_DATA = SsdpServiceInfo( + **load_json_object_fixture("ssdp_service_remote_control_receiver.json", DOMAIN) +) -SAMPLE_DEVICE_INFO_UE48JU6400 = { - "id": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", - "name": "[TV] TV-UE48JU6470", - "version": "2.0.25", - "device": { - "type": "Samsung SmartTV", - "duid": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", - "model": "15_HAWKM_UHD_2D", - "modelName": "UE48JU6400", - "description": "Samsung DTV RCR", - "networkType": "wired", - "ssid": "", - "ip": "1.2.3.4", - "firmwareVersion": "Unknown", - "name": "[TV] TV-UE48JU6470", - "id": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", - "udn": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", - "resolution": "1920x1080", - "countryCode": "AT", - "msfVersion": "2.0.25", - "smartHubAgreement": "true", - "wifiMac": "aa:bb:aa:aa:aa:aa", - "developerMode": "0", - "developerIP": "", - }, - "type": "Samsung SmartTV", - "uri": "https://1.2.3.4:8002/api/v2/", -} +MOCK_SSDP_DATA_RENDERING_CONTROL_ST = SsdpServiceInfo( + **load_json_object_fixture("ssdp_service_rendering_control.json", DOMAIN) +) -SAMPLE_EVENT_ED_INSTALLED_APP = { - "event": ED_INSTALLED_APP_EVENT, - "from": "host", - "data": { - "data": [ - { - "appId": "111299001912", - "app_type": 2, - "icon": "/opt/share/webappservice/apps_icon/FirstScreen/111299001912/250x250.png", - "is_lock": 0, - "name": "YouTube", - }, - { - "appId": "3201608010191", - "app_type": 2, - "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201608010191/250x250.png", - "is_lock": 0, - "name": "Deezer", - }, - { - "appId": "3201606009684", - "app_type": 2, - "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201606009684/250x250.png", - "is_lock": 0, - "name": "Spotify - Music and Podcasts", - }, - ] - }, -} +MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = SsdpServiceInfo( + **load_json_object_fixture("ssdp_device_main_tv_agent.json", DOMAIN) +) diff --git a/tests/components/samsungtv/fixtures/device_info_UE43LS003.json b/tests/components/samsungtv/fixtures/device_info_UE43LS003.json new file mode 100644 index 00000000000..ac961fafd6b --- /dev/null +++ b/tests/components/samsungtv/fixtures/device_info_UE43LS003.json @@ -0,0 +1,34 @@ +{ + "device": { + "FrameTVSupport": "true", + "GamePadSupport": "true", + "ImeSyncedSupport": "true", + "OS": "Tizen", + "TokenAuthSupport": "true", + "VoiceSupport": "true", + "countryCode": "FR", + "description": "Samsung DTV RCR", + "developerIP": "0.0.0.0", + "developerMode": "0", + "duid": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "firmwareVersion": "Unknown", + "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "ip": "1.2.3.4", + "model": "17_KANTM_UHD", + "modelName": "UE43LS003", + "name": "[TV] Samsung Frame (43)", + "networkType": "wired", + "resolution": "3840x2160", + "smartHubAgreement": "true", + "type": "Samsung SmartTV", + "udn": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "wifiMac": "aa:ee:tt:hh:ee:rr" + }, + "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "isSupport": "{\"DMP_DRM_PLAYREADY\":\"false\",\"DMP_DRM_WIDEVINE\":\"false\",\"DMP_available\":\"true\",\"EDEN_available\":\"true\",\"FrameTVSupport\":\"true\",\"ImeSyncedSupport\":\"true\",\"TokenAuthSupport\":\"true\",\"remote_available\":\"true\",\"remote_fourDirections\":\"true\",\"remote_touchPad\":\"true\",\"remote_voiceControl\":\"true\"}\n", + "name": "[TV] Samsung Frame (43)", + "remote": "1.0", + "type": "Samsung SmartTV", + "uri": "https://1.2.3.4:8002/api/v2/", + "version": "2.0.25" +} diff --git a/tests/components/samsungtv/fixtures/device_info_UE48JU6400.json b/tests/components/samsungtv/fixtures/device_info_UE48JU6400.json new file mode 100644 index 00000000000..65cecf095a2 --- /dev/null +++ b/tests/components/samsungtv/fixtures/device_info_UE48JU6400.json @@ -0,0 +1,28 @@ +{ + "id": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", + "name": "[TV] TV-UE48JU6470", + "version": "2.0.25", + "device": { + "type": "Samsung SmartTV", + "duid": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", + "model": "15_HAWKM_UHD_2D", + "modelName": "UE48JU6400", + "description": "Samsung DTV RCR", + "networkType": "wired", + "ssid": "", + "ip": "1.2.3.4", + "firmwareVersion": "Unknown", + "name": "[TV] TV-UE48JU6470", + "id": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", + "udn": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", + "resolution": "1920x1080", + "countryCode": "AT", + "msfVersion": "2.0.25", + "smartHubAgreement": "true", + "wifiMac": "aa:bb:aa:aa:aa:aa", + "developerMode": "0", + "developerIP": "" + }, + "type": "Samsung SmartTV", + "uri": "https://1.2.3.4:8002/api/v2/" +} diff --git a/tests/components/samsungtv/fixtures/ssdp_device_main_tv_agent.json b/tests/components/samsungtv/fixtures/ssdp_device_main_tv_agent.json new file mode 100644 index 00000000000..252d352f514 --- /dev/null +++ b/tests/components/samsungtv/fixtures/ssdp_device_main_tv_agent.json @@ -0,0 +1,54 @@ +{ + "ssdp_usn": "uuid:055d4a80-005a-1000-b872-84a4668d8423::urn:samsung.com:service:MainTVAgent2:1", + "ssdp_st": "urn:samsung.com:service:MainTVAgent2:1", + "upnp": { + "deviceType": "urn:samsung.com:device:MainTVServer2:1", + "friendlyName": "[TV]Samsung LED55", + "manufacturer": "Samsung Electronics", + "manufacturerURL": "http://www.samsung.com", + "modelDescription": "Samsung DTV MainTVServer2", + "modelName": "UE55H6400", + "modelNumber": "1.0", + "modelURL": "http://www.samsung.com", + "serialNumber": "20100621", + "UDN": "uuid:055d4a80-005a-1000-b872-84a4668d8423", + "UPC": "123456789012", + "deviceID": "ZPCNHA5IWYRV6", + "ProductCap": "Y2013", + "serviceList": { + "service": { + "serviceType": "urn:samsung.com:service:MainTVAgent2:1", + "serviceId": "urn:samsung.com:serviceId:MainTVAgent2", + "controlURL": "/smp_4_", + "eventSubURL": "/smp_5_", + "SCPDURL": "/smp_3_" + } + } + }, + "ssdp_location": "http://10.10.12.34:7676/smp_2_", + "ssdp_nt": null, + "ssdp_udn": "uuid:055d4a80-005a-1000-b872-84a4668d8423", + "ssdp_ext": "", + "ssdp_server": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ssdp_headers": { + "CACHE-CONTROL": "max-age:1800", + "Date": "Thu, 01 Jan 1970 00:06:48 GMT", + "EXT": "", + "LOCATION": "http://10.10.12.34:7676/smp_2_", + "SERVER": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ST": "urn:samsung.com:service:MainTVAgent2:1", + "USN": "uuid:055d4a80-005a-1000-b872-84a4668d8423::urn:samsung.com:service:MainTVAgent2:1", + "Content-Length": "0", + "_host": "10.10.12.34", + "_udn": "uuid:055d4a80-005a-1000-b872-84a4668d8423", + "_location_original": "http://10.10.12.34:7676/smp_2_", + "location": "http://10.10.12.34:7676/smp_2_", + "_timestamp": "2025-04-30T07:30:24.160549", + "_remote_addr": ["10.10.12.34", 58482], + "_port": 58482, + "_local_addr": ["0.0.0.0", 0], + "_source": "search" + }, + "ssdp_all_locations": ["http://10.10.12.34:7676/smp_2_"], + "x_homeassistant_matching_domains": ["samsungtv"] +} diff --git a/tests/components/samsungtv/fixtures/ssdp_service_remote_control_receiver.json b/tests/components/samsungtv/fixtures/ssdp_service_remote_control_receiver.json new file mode 100644 index 00000000000..21cd39a65a9 --- /dev/null +++ b/tests/components/samsungtv/fixtures/ssdp_service_remote_control_receiver.json @@ -0,0 +1,62 @@ +{ + "ssdp_usn": "uuid:068e7781-006e-1000-bbbf-84a4668d8423::urn:samsung.com:device:RemoteControlReceiver:1", + "ssdp_st": "urn:samsung.com:device:RemoteControlReceiver:1", + "upnp": { + "deviceType": "urn:samsung.com:device:RemoteControlReceiver:1", + "friendlyName": "[TV]Samsung LED55", + "manufacturer": "Samsung Electronics", + "manufacturerURL": "http://www.samsung.com/sec", + "modelDescription": "Samsung TV RCR", + "modelName": "UE55H6400", + "modelNumber": "1.0", + "modelURL": "http://www.samsung.com/sec", + "serialNumber": "20090804RCR", + "UDN": "uuid:068e7781-006e-1000-bbbf-84a4668d8423", + "deviceID": "ZPCNHA5IWYRV6", + "ProductCap": "Resolution:1920X1080,ImageZoom,ImageRotate,Y2014,ENC", + "serviceList": { + "service": { + "serviceType": "urn:samsung.com:service:MultiScreenService:1", + "serviceId": "urn:samsung.com:serviceId:MultiScreenService", + "controlURL": "/smp_9_", + "eventSubURL": "/smp_10_", + "SCPDURL": "/smp_8_" + } + }, + "Capabilities": { + "Capability": { + "@name": "samsung:multiscreen:1", + "@port": "8001", + "@location": "/ms/1.0/" + } + } + }, + "ssdp_location": "http://10.10.12.34:7676/smp_7_", + "ssdp_nt": "urn:samsung.com:device:RemoteControlReceiver:1", + "ssdp_udn": "uuid:068e7781-006e-1000-bbbf-84a4668d8423", + "ssdp_ext": "", + "ssdp_server": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ssdp_headers": { + "CACHE-CONTROL": "max-age:1800", + "Date": "Thu, 01 Jan 1970 00:06:48 GMT", + "EXT": "", + "LOCATION": "http://10.10.12.34:7676/smp_7_", + "SERVER": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ST": "urn:samsung.com:device:RemoteControlReceiver:1", + "USN": "uuid:068e7781-006e-1000-bbbf-84a4668d8423::urn:samsung.com:device:RemoteControlReceiver:1", + "Content-Length": "0", + "_host": "10.10.12.34", + "_udn": "uuid:068e7781-006e-1000-bbbf-84a4668d8423", + "_location_original": "http://10.10.12.34:7676/smp_7_", + "location": "http://10.10.12.34:7676/smp_7_", + "_timestamp": "2025-04-30T07:30:24.384758", + "_remote_addr": ["10.10.12.34", 24234], + "_port": 24234, + "_local_addr": ["0.0.0.0", 1900], + "HOST": "239.255.255.250:1900", + "NT": "urn:samsung.com:device:RemoteControlReceiver:1", + "NTS": "ssdp:alive" + }, + "ssdp_all_locations": ["http://10.10.12.34:7676/smp_7_"], + "x_homeassistant_matching_domains": ["samsungtv"] +} diff --git a/tests/components/samsungtv/fixtures/ssdp_service_rendering_control.json b/tests/components/samsungtv/fixtures/ssdp_service_rendering_control.json new file mode 100644 index 00000000000..31c0944e0ac --- /dev/null +++ b/tests/components/samsungtv/fixtures/ssdp_service_rendering_control.json @@ -0,0 +1,105 @@ +{ + "ssdp_usn": "uuid:09896802-00a0-1000-adfd-84a4668d8423::urn:schemas-upnp-org:service:RenderingControl:1", + "ssdp_st": "urn:schemas-upnp-org:service:RenderingControl:1", + "upnp": { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "X_compatibleId": "MS_DigitalMediaDeviceClass_DMR_V001", + "X_deviceCategory": "Display.TV.LCD Multimedia.DMR", + "X_DLNADOC": "DMR-1.50", + "friendlyName": "[TV]Samsung LED55", + "manufacturer": "Samsung Electronics", + "manufacturerURL": "http://www.samsung.com/sec", + "modelDescription": "Samsung TV DMR", + "modelName": "UE55H6400", + "modelNumber": "AllShare1.0", + "modelURL": "http://www.samsung.com/sec", + "serialNumber": "20110517DMR", + "UDN": "uuid:09896802-00a0-1000-adfd-84a4668d8423", + "deviceID": "ZPCNHA5IWYRV6", + "iconList": { + "icon": [ + { + "mimetype": "image/jpeg", + "width": "48", + "height": "48", + "depth": "24", + "url": "/dmr/icon_SML.jpg" + }, + { + "mimetype": "image/jpeg", + "width": "120", + "height": "120", + "depth": "24", + "url": "/dmr/icon_LRG.jpg" + }, + { + "mimetype": "image/png", + "width": "48", + "height": "48", + "depth": "24", + "url": "/dmr/icon_SML.png" + }, + { + "mimetype": "image/png", + "width": "120", + "height": "120", + "depth": "24", + "url": "/dmr/icon_LRG.png" + } + ] + }, + "serviceList": { + "service": [ + { + "serviceType": "urn:schemas-upnp-org:service:RenderingControl:1", + "serviceId": "urn:upnp-org:serviceId:RenderingControl", + "controlURL": "/smp_17_", + "eventSubURL": "/smp_18_", + "SCPDURL": "/smp_16_" + }, + { + "serviceType": "urn:schemas-upnp-org:service:ConnectionManager:1", + "serviceId": "urn:upnp-org:serviceId:ConnectionManager", + "controlURL": "/smp_20_", + "eventSubURL": "/smp_21_", + "SCPDURL": "/smp_19_" + }, + { + "serviceType": "urn:schemas-upnp-org:service:AVTransport:1", + "serviceId": "urn:upnp-org:serviceId:AVTransport", + "controlURL": "/smp_23_", + "eventSubURL": "/smp_24_", + "SCPDURL": "/smp_22_" + } + ] + }, + "ProductCap": "Y2014,WebURIPlayable,SeekTRACK_NR,NavigateInPause", + "X_hardwareId": "VEN_0105&DEV_VD0001" + }, + "ssdp_location": "http://10.10.12.34:7676/smp_15_", + "ssdp_nt": null, + "ssdp_udn": "uuid:09896802-00a0-1000-adfd-84a4668d8423", + "ssdp_ext": "", + "ssdp_server": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ssdp_headers": { + "CACHE-CONTROL": "max-age:1800", + "Date": "Thu, 01 Jan 1970 00:06:48 GMT", + "EXT": "", + "LOCATION": "http://10.10.12.34:7676/smp_15_", + "SERVER": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ST": "urn:schemas-upnp-org:service:RenderingControl:1", + "USN": "uuid:09896802-00a0-1000-adfd-84a4668d8423::urn:schemas-upnp-org:service:RenderingControl:1", + "Content-Length": "0", + "_host": "10.10.12.34", + "_udn": "uuid:09896802-00a0-1000-adfd-84a4668d8423", + "_location_original": "http://10.10.12.34:7676/smp_15_", + "location": "http://10.10.12.34:7676/smp_15_", + "_timestamp": "2025-04-30T07:30:24.146243", + "_remote_addr": ["10.10.12.34", 52226], + "_port": 52226, + "_local_addr": ["0.0.0.0", 0], + "_source": "search" + }, + "ssdp_all_locations": ["http://10.10.12.34:7676/smp_15_"], + "x_homeassistant_matching_domains": ["samsungtv"] +} diff --git a/tests/components/samsungtv/fixtures/ws_installed_app_event.json b/tests/components/samsungtv/fixtures/ws_installed_app_event.json new file mode 100644 index 00000000000..81c64f60958 --- /dev/null +++ b/tests/components/samsungtv/fixtures/ws_installed_app_event.json @@ -0,0 +1,29 @@ +{ + "event": "ed.installedApp.get", + "from": "host", + "data": { + "data": [ + { + "appId": "111299001912", + "app_type": 2, + "icon": "/opt/share/webappservice/apps_icon/FirstScreen/111299001912/250x250.png", + "is_lock": 0, + "name": "YouTube" + }, + { + "appId": "3201608010191", + "app_type": 2, + "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201608010191/250x250.png", + "is_lock": 0, + "name": "Deezer" + }, + { + "appId": "3201606009684", + "app_type": 2, + "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201606009684/250x250.png", + "is_lock": 0, + "name": "Spotify - Music and Podcasts" + } + ] + } +} diff --git a/tests/components/samsungtv/snapshots/test_diagnostics.ambr b/tests/components/samsungtv/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..fb7bcd83ae7 --- /dev/null +++ b/tests/components/samsungtv/snapshots/test_diagnostics.ambr @@ -0,0 +1,131 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_info': dict({ + 'device': dict({ + 'modelName': '82GXARRS', + 'name': '[TV] Living Room', + 'networkType': 'wireless', + 'type': 'Samsung SmartTV', + 'wifiMac': 'aa:bb:aa:aa:aa:aa', + }), + 'id': 'uuid:be9554b9-c9fb-41f4-8920-22da015376a4', + }), + 'entry': dict({ + 'data': dict({ + 'host': '10.10.12.34', + 'mac': 'aa:bb:cc:dd:ee:ff', + 'method': 'websocket', + 'model': 'UE43LS003', + 'port': 8002, + 'token': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'samsungtv', + 'entry_id': '123456', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'be9554b9-c9fb-41f4-8920-22da015376a4', + 'version': 2, + }), + }) +# --- +# name: test_entry_diagnostics_encrypte_offline + dict({ + 'device_info': None, + 'entry': dict({ + 'data': dict({ + 'host': '10.10.12.34', + 'mac': 'aa:bb:cc:dd:ee:ff', + 'method': 'encrypted', + 'port': 8000, + 'session_id': '**REDACTED**', + 'token': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'samsungtv', + 'entry_id': '123456', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'be9554b9-c9fb-41f4-8920-22da015376a4', + 'version': 2, + }), + }) +# --- +# name: test_entry_diagnostics_encrypted + dict({ + 'device_info': dict({ + 'device': dict({ + 'countryCode': 'AT', + 'description': 'Samsung DTV RCR', + 'developerIP': '', + 'developerMode': '0', + 'duid': 'uuid:223da676-497a-4e06-9507-5e27ec4f0fb3', + 'firmwareVersion': 'Unknown', + 'id': 'uuid:223da676-497a-4e06-9507-5e27ec4f0fb3', + 'ip': '1.2.3.4', + 'model': '15_HAWKM_UHD_2D', + 'modelName': 'UE48JU6400', + 'msfVersion': '2.0.25', + 'name': '[TV] TV-UE48JU6470', + 'networkType': 'wired', + 'resolution': '1920x1080', + 'smartHubAgreement': 'true', + 'ssid': '', + 'type': 'Samsung SmartTV', + 'udn': 'uuid:223da676-497a-4e06-9507-5e27ec4f0fb3', + 'wifiMac': 'aa:bb:aa:aa:aa:aa', + }), + 'id': 'uuid:223da676-497a-4e06-9507-5e27ec4f0fb3', + 'name': '[TV] TV-UE48JU6470', + 'type': 'Samsung SmartTV', + 'uri': 'https://1.2.3.4:8002/api/v2/', + 'version': '2.0.25', + }), + 'entry': dict({ + 'data': dict({ + 'host': '10.10.12.34', + 'mac': 'aa:bb:cc:dd:ee:ff', + 'method': 'encrypted', + 'model': 'UE48JU6400', + 'port': 8000, + 'session_id': '**REDACTED**', + 'token': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'samsungtv', + 'entry_id': '123456', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'be9554b9-c9fb-41f4-8920-22da015376a4', + 'version': 2, + }), + }) +# --- diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index db175626d41..b29b824a7dd 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_cleanup_mac +# name: test_setup[encrypted] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -11,10 +11,6 @@ 'mac', 'aa:bb:cc:dd:ee:ff', ), - tuple( - 'mac', - 'none', - ), }), 'disabled_by': None, 'entry_type': None, @@ -23,16 +19,16 @@ 'identifiers': set({ tuple( 'samsungtv', - 'any', + 'be9554b9-c9fb-41f4-8920-22da015376a4', ), }), 'is_new': False, 'labels': set({ }), 'manufacturer': None, - 'model': '82GXARRS', + 'model': None, 'model_id': None, - 'name': 'fake', + 'name': 'Mock Title', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, @@ -42,7 +38,42 @@ }), ]) # --- -# name: test_cleanup_mac.1 +# name: test_setup[legacy] + 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( + 'samsungtv', + '123456', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Mock Title', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_setup[websocket] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -54,10 +85,6 @@ 'mac', 'aa:bb:cc:dd:ee:ff', ), - tuple( - 'mac', - 'none', - ), }), 'disabled_by': None, 'entry_type': None, @@ -66,16 +93,16 @@ 'identifiers': set({ tuple( 'samsungtv', - 'any', + 'be9554b9-c9fb-41f4-8920-22da015376a4', ), }), 'is_new': False, 'labels': set({ }), 'manufacturer': None, - 'model': '82GXARRS', - 'model_id': '82GXARRS', - 'name': 'fake', + 'model': None, + 'model_id': 'UE43LS003', + 'name': 'Mock Title', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, @@ -85,62 +112,3 @@ }), ]) # --- -# name: test_setup_updates_from_ssdp - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'tv', - 'friendly_name': 'any', - 'is_volume_muted': False, - 'source_list': list([ - 'TV', - 'HDMI', - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'media_player.any', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_setup_updates_from_ssdp.1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'source_list': list([ - 'TV', - 'HDMI', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'media_player', - 'entity_category': None, - 'entity_id': 'media_player.any', - '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': 'samsungtv', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'sample-entry-id', - 'unit_of_measurement': None, - }) -# --- diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 5ff259c2120..25c8bf9bab9 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -2,6 +2,7 @@ from copy import deepcopy from ipaddress import ip_address +import socket from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest @@ -17,7 +18,10 @@ from websockets import frames from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant import config_entries -from homeassistant.components.samsungtv.config_flow import SamsungTVConfigFlow +from homeassistant.components.samsungtv.config_flow import ( + SamsungTVConfigFlow, + _strip_uuid, +) from homeassistant.components.samsungtv.const import ( CONF_MANUFACTURER, CONF_SESSION_ID, @@ -26,6 +30,7 @@ from homeassistant.components.samsungtv.const import ( DEFAULT_MANUFACTURER, DOMAIN, LEGACY_PORT, + METHOD_LEGACY, RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT, RESULT_NOT_SUPPORTED, @@ -35,103 +40,44 @@ from homeassistant.components.samsungtv.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, - CONF_ID, - CONF_IP_ADDRESS, CONF_MAC, CONF_METHOD, CONF_MODEL, - CONF_NAME, CONF_PIN, CONF_PORT, CONF_TOKEN, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, SsdpServiceInfo, ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from .const import ( - MOCK_ENTRYDATA_ENCRYPTED_WS, - MOCK_ENTRYDATA_WS, + ENTRYDATA_ENCRYPTED_WEBSOCKET, + ENTRYDATA_LEGACY, + ENTRYDATA_WEBSOCKET, + MOCK_SSDP_DATA, MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - SAMPLE_DEVICE_INFO_FRAME, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture RESULT_ALREADY_CONFIGURED = "already_configured" RESULT_ALREADY_IN_PROGRESS = "already_in_progress" -MOCK_IMPORT_DATA = { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 55000, -} -MOCK_IMPORT_DATA_WITHOUT_NAME = { - CONF_HOST: "fake_host", -} -MOCK_IMPORT_WSDATA = { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 8002, -} -MOCK_USER_DATA = {CONF_HOST: "fake_host", CONF_NAME: "fake_name"} -MOCK_SSDP_DATA = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="https://fake_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", - }, -) -MOCK_SSDP_DATA_NO_MANUFACTURER = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="https://fake_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", - }, -) +MOCK_USER_DATA = {CONF_HOST: "fake_host"} -MOCK_SSDP_DATA_NOPREFIX = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="http://fake2_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "fake2_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake2_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake2_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", - }, -) -MOCK_SSDP_DATA_WRONGMODEL = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="http://fake2_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "fake2_name", - ATTR_UPNP_MANUFACTURER: "fake2_manufacturer", - ATTR_UPNP_MODEL_NAME: "HW-Qfake", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", - }, -) MOCK_DHCP_DATA = DhcpServiceInfo( - ip="fake_host", macaddress="aabbccddeeff", hostname="fake_hostname" + ip="10.10.12.34", macaddress="aabbccddeeff", hostname="fake_hostname" ) -EXISTING_IP = "192.168.40.221" MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], @@ -146,19 +92,6 @@ MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( }, type="mock_type", ) -MOCK_OLD_ENTRY = { - CONF_HOST: "fake_host", - CONF_ID: "0d1cef00-00dc-1000-9c80-4844f7b172de_old", - CONF_IP_ADDRESS: EXISTING_IP, - CONF_METHOD: "legacy", - CONF_PORT: None, -} -MOCK_LEGACY_ENTRY = { - CONF_HOST: EXISTING_IP, - CONF_ID: "0d1cef00-00dc-1000-9c80-4844f7b172de_old", - CONF_METHOD: "legacy", - CONF_PORT: None, -} MOCK_DEVICE_INFO = { "device": { "type": "Samsung SmartTV", @@ -172,42 +105,29 @@ AUTODETECT_LEGACY = { "name": "HomeAssistant", "description": "HomeAssistant", "id": "ha.component.samsung", - "method": "legacy", + "method": METHOD_LEGACY, "port": LEGACY_PORT, - "host": "fake_host", + "host": "10.20.43.21", "timeout": TIMEOUT_REQUEST, } -AUTODETECT_WEBSOCKET_PLAIN = { - "host": "fake_host", - "name": "HomeAssistant", - "port": 8001, - "timeout": TIMEOUT_REQUEST, - "token": None, -} AUTODETECT_WEBSOCKET_SSL = { - "host": "fake_host", + "host": "10.20.43.21", "name": "HomeAssistant", "port": 8002, "timeout": TIMEOUT_REQUEST, "token": None, } DEVICEINFO_WEBSOCKET_SSL = { - "host": "fake_host", + "host": "10.20.43.21", "session": ANY, "port": 8002, "timeout": TIMEOUT_WEBSOCKET, } -DEVICEINFO_WEBSOCKET_NO_SSL = { - "host": "fake_host", - "session": ANY, - "port": 8001, - "timeout": TIMEOUT_WEBSOCKET, -} pytestmark = pytest.mark.usefixtures("mock_setup_entry") -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_user_legacy(hass: HomeAssistant) -> None: """Test starting a flow by user.""" # show form @@ -217,16 +137,28 @@ async def test_user_legacy(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - # entry was added + # Wrong host allow to retry + with patch( + "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", + side_effect=socket.gaierror("[Error -2] Name or Service not known"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_host"} + + # Good host creates entry result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA ) # legacy tv entry created assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake_name" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake_name" - assert result["data"][CONF_METHOD] == "legacy" + assert result["title"] == "10.20.43.21" + assert result["data"][CONF_HOST] == "10.20.43.21" + assert result["data"][CONF_METHOD] == METHOD_LEGACY assert result["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result["data"][CONF_MODEL] is None assert result["result"].unique_id is None @@ -258,16 +190,17 @@ async def test_user_legacy_does_not_ok_first_time(hass: HomeAssistant) -> None: # legacy tv entry created assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "fake_name" - assert result3["data"][CONF_HOST] == "fake_host" - assert result3["data"][CONF_NAME] == "fake_name" - assert result3["data"][CONF_METHOD] == "legacy" + assert result3["title"] == "10.20.43.21" + assert result3["data"][CONF_HOST] == "10.20.43.21" + assert result3["data"][CONF_METHOD] == METHOD_LEGACY assert result3["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result3["data"][CONF_MODEL] is None assert result3["result"].unique_id is None -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_user_websocket(hass: HomeAssistant) -> None: """Test starting a flow by user.""" with patch( @@ -287,15 +220,14 @@ async def test_user_websocket(hass: HomeAssistant) -> None: # websocket tv entry created assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Living Room" + assert result["data"][CONF_HOST] == "10.20.43.21" assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remoteencws", "rest_api_non_ssl_only") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api_non_ssl_only") async def test_user_encrypted_websocket( hass: HomeAssistant, ) -> None: @@ -336,8 +268,7 @@ async def test_user_encrypted_websocket( assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == "TV-UE48JU6470 (UE48JU6400)" - assert result4["data"][CONF_HOST] == "fake_host" - assert result4["data"][CONF_NAME] == "TV-UE48JU6470" + assert result4["data"][CONF_HOST] == "10.20.43.21" assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result4["data"][CONF_MANUFACTURER] == "Samsung" assert result4["data"][CONF_MODEL] == "UE48JU6400" @@ -389,7 +320,7 @@ async def test_user_legacy_not_supported(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("rest_api", "remote_encrypted_websocket_failing") async def test_user_websocket_not_supported(hass: HomeAssistant) -> None: """Test starting a flow by user for not supported device.""" with ( @@ -410,7 +341,7 @@ async def test_user_websocket_not_supported(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("rest_api", "remote_encrypted_websocket_failing") async def test_user_websocket_access_denied( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -434,7 +365,7 @@ async def test_user_websocket_access_denied( assert "Please check the Device Connection Manager on your TV" in caplog.text -@pytest.mark.usefixtures("rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("rest_api", "remote_encrypted_websocket_failing") async def test_user_websocket_auth_retry(hass: HomeAssistant) -> None: """Test starting a flow by user for not supported device.""" with ( @@ -468,8 +399,7 @@ async def test_user_websocket_auth_retry(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Living Room" + assert result["data"][CONF_HOST] == "10.20.43.21" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -515,7 +445,7 @@ async def test_user_not_successful_2(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_CANNOT_CONNECT -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_ssdp(hass: HomeAssistant) -> None: """Test starting a flow from discovery.""" # confirm to add the entry @@ -530,30 +460,32 @@ async def test_ssdp(hass: HomeAssistant) -> None: result["flow_id"], user_input="whatever" ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake_model" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake_model" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" - assert result["data"][CONF_MODEL] == "fake_model" - assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" + assert result["title"] == "UE55H6400" + assert result["data"][CONF_HOST] == "10.10.12.34" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" + assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_ssdp_no_manufacturer(hass: HomeAssistant) -> None: """Test starting a flow from discovery when the manufacturer data is missing.""" + ssdp_data = deepcopy(MOCK_SSDP_DATA) + ssdp_data.upnp.pop(ATTR_UPNP_MANUFACTURER) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_NO_MANUFACTURER, + data=ssdp_data, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED @pytest.mark.parametrize( "data", [MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, MOCK_SSDP_DATA_RENDERING_CONTROL_ST] ) -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_ssdp_legacy_not_remote_control_receiver_udn( hass: HomeAssistant, data: SsdpServiceInfo ) -> None: @@ -565,14 +497,19 @@ async def test_ssdp_legacy_not_remote_control_receiver_udn( assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_ssdp_noprefix(hass: HomeAssistant) -> None: - """Test starting a flow from discovery without prefixes.""" + """Test starting a flow from discovery when friendly name doesn't start with [TV].""" + ssdp_data = deepcopy(MOCK_SSDP_DATA) + ssdp_data.upnp[ATTR_UPNP_FRIENDLY_NAME] = ssdp_data.upnp[ATTR_UPNP_FRIENDLY_NAME][ + 4: + ] + # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_NOPREFIX, + data=ssdp_data, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -581,15 +518,14 @@ async def test_ssdp_noprefix(hass: HomeAssistant) -> None: result["flow_id"], user_input="whatever" ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake2_model" - assert result["data"][CONF_HOST] == "fake2_host" - assert result["data"][CONF_NAME] == "fake2_model" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake2_manufacturer" - assert result["data"][CONF_MODEL] == "fake2_model" - assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172df" + assert result["title"] == "UE55H6400" + assert result["data"][CONF_HOST] == "10.10.12.34" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" + assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" -@pytest.mark.usefixtures("remotews", "rest_api_failing") +@pytest.mark.usefixtures("remote_websocket", "rest_api_failing") async def test_ssdp_legacy_missing_auth(hass: HomeAssistant) -> None: """Test starting a flow from discovery with authentication.""" with patch( @@ -617,15 +553,14 @@ async def test_ssdp_legacy_missing_auth(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake_model" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake_model" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" - assert result["data"][CONF_MODEL] == "fake_model" - assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" + assert result["title"] == "UE55H6400" + assert result["data"][CONF_HOST] == "10.10.12.34" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" + assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" -@pytest.mark.usefixtures("remotews", "rest_api_failing") +@pytest.mark.usefixtures("remote_websocket", "rest_api_failing") async def test_ssdp_legacy_not_supported(hass: HomeAssistant) -> None: """Test starting a flow from discovery for not supported device.""" with patch( @@ -640,7 +575,9 @@ async def test_ssdp_legacy_not_supported(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_ssdp_websocket_success_populates_mac_address_and_ssdp_location( hass: HomeAssistant, ) -> None: @@ -658,19 +595,20 @@ async def test_ssdp_websocket_success_populates_mac_address_and_ssdp_location( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Living Room" + assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" assert ( result["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_ssdp_websocket_success_populates_mac_address_and_main_tv_ssdp_location( hass: HomeAssistant, ) -> None: @@ -688,19 +626,18 @@ async def test_ssdp_websocket_success_populates_mac_address_and_main_tv_ssdp_loc ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Living Room" + assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" assert ( result["data"][CONF_SSDP_MAIN_TV_AGENT_LOCATION] - == "https://fake_host:12345/tv_agent" + == "http://10.10.12.34:7676/smp_2_" ) assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remoteencws", "rest_api_non_ssl_only") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api_non_ssl_only") async def test_ssdp_encrypted_websocket_success_populates_mac_address_and_ssdp_location( hass: HomeAssistant, ) -> None: @@ -740,14 +677,13 @@ async def test_ssdp_encrypted_websocket_success_populates_mac_address_and_ssdp_l assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == "TV-UE48JU6470 (UE48JU6400)" - assert result4["data"][CONF_HOST] == "fake_host" - assert result4["data"][CONF_NAME] == "TV-UE48JU6470" + assert result4["data"][CONF_HOST] == "10.10.12.34" assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" - assert result4["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" + assert result4["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result4["data"][CONF_MODEL] == "UE48JU6400" assert ( result4["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert result4["data"][CONF_TOKEN] == "037739871315caef138547b03e348b72" assert result4["data"][CONF_SESSION_ID] == "1" @@ -786,8 +722,8 @@ async def test_ssdp_websocket_cannot_connect(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote", - ) as remotews, - patch.object(remotews, "open", side_effect=WebSocketException("Boom")), + ) as remote_websocket, + patch.object(remote_websocket, "open", side_effect=WebSocketException("Boom")), ): # device not supported result = await hass.config_entries.flow.async_init( @@ -797,21 +733,22 @@ async def test_ssdp_websocket_cannot_connect(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_CANNOT_CONNECT -@pytest.mark.usefixtures("remote") -async def test_ssdp_model_not_supported(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("remote_legacy") +async def test_ssdp_wrong_manufacturer(hass: HomeAssistant) -> None: """Test starting a flow from discovery.""" - + ssdp_data = deepcopy(MOCK_SSDP_DATA) + ssdp_data.upnp[ATTR_UPNP_MANUFACTURER] = ssdp_data.upnp[ATTR_UPNP_MANUFACTURER][7:] # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_WRONGMODEL, + data=ssdp_data, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remoteencws_failing") +@pytest.mark.usefixtures("remote_encrypted_websocket_failing") async def test_ssdp_not_successful(hass: HomeAssistant) -> None: """Test starting a flow from discovery but no device found.""" with ( @@ -843,7 +780,7 @@ async def test_ssdp_not_successful(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_CANNOT_CONNECT -@pytest.mark.usefixtures("remoteencws_failing") +@pytest.mark.usefixtures("remote_encrypted_websocket_failing") async def test_ssdp_not_successful_2(hass: HomeAssistant) -> None: """Test starting a flow from discovery but no device found.""" with ( @@ -875,7 +812,7 @@ async def test_ssdp_not_successful_2(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_CANNOT_CONNECT -@pytest.mark.usefixtures("remote", "remoteencws_failing") +@pytest.mark.usefixtures("remote_legacy", "remote_encrypted_websocket_failing") async def test_ssdp_already_in_progress(hass: HomeAssistant) -> None: """Test starting a flow from discovery twice.""" with patch( @@ -897,7 +834,7 @@ async def test_ssdp_already_in_progress(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_ALREADY_IN_PROGRESS -@pytest.mark.usefixtures("remotews", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket", "remote_encrypted_websocket_failing") async def test_ssdp_already_configured(hass: HomeAssistant) -> None: """Test starting a flow from discovery when already configured.""" with patch( @@ -925,7 +862,9 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: assert entry.unique_id == "123" -@pytest.mark.usefixtures("remotews", "rest_api_non_ssl_only", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api_non_ssl_only", "remote_encrypted_websocket_failing" +) async def test_dhcp_wireless(hass: HomeAssistant) -> None: """Test starting a flow from dhcp.""" # confirm to add the entry @@ -944,19 +883,22 @@ async def test_dhcp_wireless(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "TV-UE48JU6470 (UE48JU6400)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "TV-UE48JU6470" + assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE48JU6400" assert result["result"].unique_id == "223da676-497a-4e06-9507-5e27ec4f0fb3" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_dhcp_wired(hass: HomeAssistant, rest_api: Mock) -> None: """Test starting a flow from dhcp.""" # Even though it is named "wifiMac", it matches the mac of the wired connection - rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_FRAME + rest_api.rest_device_info.return_value = load_json_object_fixture( + "device_info_UE43LS003.json", DOMAIN + ) # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, @@ -973,15 +915,16 @@ async def test_dhcp_wired(hass: HomeAssistant, rest_api: Mock) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Samsung Frame (43) (UE43LS003)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Samsung Frame (43)" + assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MAC] == "aa:ee:tt:hh:ee:rr" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE43LS003" assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api_non_ssl_only", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api_non_ssl_only", "remote_encrypted_websocket_failing" +) @pytest.mark.parametrize( ("source1", "data1", "source2", "data2", "is_matching_result"), [ @@ -1053,7 +996,9 @@ async def test_dhcp_zeroconf_already_in_progress( assert return_values == [is_matching_result] -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_zeroconf(hass: HomeAssistant) -> None: """Test starting a flow from zeroconf.""" result = await hass.config_entries.flow.async_init( @@ -1072,14 +1017,13 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" assert result["data"][CONF_HOST] == "127.0.0.1" - assert result["data"][CONF_NAME] == "Living Room" assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket", "remote_encrypted_websocket_failing") async def test_zeroconf_ignores_soundbar(hass: HomeAssistant, rest_api: Mock) -> None: """Test starting a flow from zeroconf where the device is actually a soundbar.""" rest_api.rest_device_info.return_value = { @@ -1099,10 +1043,15 @@ async def test_zeroconf_ignores_soundbar(hass: HomeAssistant, rest_api: Mock) -> ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remote", "remotews", "remoteencws", "rest_api_failing") +@pytest.mark.usefixtures( + "remote_legacy", + "remote_websocket", + "remote_encrypted_websocket", + "rest_api_failing", +) async def test_zeroconf_no_device_info(hass: HomeAssistant) -> None: """Test starting a flow from zeroconf where device_info returns None.""" result = await hass.config_entries.flow.async_init( @@ -1112,10 +1061,12 @@ async def test_zeroconf_no_device_info(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_zeroconf_and_dhcp_same_time(hass: HomeAssistant) -> None: """Test starting a flow from zeroconf and dhcp.""" result = await hass.config_entries.flow.async_init( @@ -1137,7 +1088,7 @@ async def test_zeroconf_and_dhcp_same_time(hass: HomeAssistant) -> None: assert result2["reason"] == "already_in_progress" -@pytest.mark.usefixtures("remoteencws_failing") +@pytest.mark.usefixtures("remote_encrypted_websocket_failing") async def test_autodetect_websocket(hass: HomeAssistant) -> None: """Test for send key with autodetection of protocol.""" with ( @@ -1147,7 +1098,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" - ) as remotews, + ) as remote_websocket, patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", ) as rest_api_class, @@ -1170,7 +1121,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: } ) remote.token = "123456789" - remotews.return_value = remote + remote_websocket.return_value = remote result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA @@ -1178,7 +1129,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" - remotews.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) + remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -1187,7 +1138,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: assert entries[0].data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" -@pytest.mark.usefixtures("remoteencws_failing") +@pytest.mark.usefixtures("remote_encrypted_websocket_failing") async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: """Test for send key with autodetection of protocol.""" mac_address.return_value = "gg:ee:tt:mm:aa:cc" @@ -1198,7 +1149,7 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" - ) as remotews, + ) as remote_websocket, patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", ) as rest_api_class, @@ -1220,7 +1171,7 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: ) remote.token = "123456789" - remotews.return_value = remote + remote_websocket.return_value = remote result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA @@ -1229,7 +1180,7 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" assert result["data"][CONF_MAC] == "gg:ee:tt:mm:aa:cc" - remotews.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) + remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -1283,15 +1234,14 @@ async def test_autodetect_not_supported(hass: HomeAssistant) -> None: assert remote.call_args_list == [call(AUTODETECT_LEGACY)] -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_autodetect_legacy(hass: HomeAssistant) -> None: """Test for send key with autodetection of protocol.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_METHOD] == "legacy" - assert result["data"][CONF_NAME] == "fake_name" + assert result["data"][CONF_METHOD] == METHOD_LEGACY assert result["data"][CONF_MAC] is None assert result["data"][CONF_PORT] == LEGACY_PORT @@ -1320,17 +1270,17 @@ async def test_autodetect_none(hass: HomeAssistant) -> None: assert rest_device_info.call_count == 2 -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_old_entry(hass: HomeAssistant) -> None: - """Test update of old entry.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY) + """Test update of old entry sets unique id.""" + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_LEGACY) entry.add_to_hass(hass) config_entries_domain = hass.config_entries.async_entries(DOMAIN) assert len(config_entries_domain) == 1 assert entry is config_entries_domain[0] - assert entry.data[CONF_ID] == "0d1cef00-00dc-1000-9c80-4844f7b172de_old" - assert entry.data[CONF_IP_ADDRESS] == EXISTING_IP assert not entry.unique_id assert await async_setup_component(hass, DOMAIN, {}) is True @@ -1348,17 +1298,18 @@ async def test_update_old_entry(hass: HomeAssistant) -> None: entry2 = config_entries_domain[0] # check updated device info - assert entry2.data.get(CONF_ID) is not None - assert entry2.data.get(CONF_IP_ADDRESS) is not None assert entry2.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_update_missing_mac_unique_id_added_from_dhcp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac and unique id added.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) + # Incorrect MAC cleanup introduced in #110599, can be removed in 2026.3 + entry_data = deepcopy(ENTRYDATA_WEBSOCKET) + del entry_data[CONF_MAC] + entry = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id=None) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -1375,12 +1326,14 @@ async def test_update_missing_mac_unique_id_added_from_dhcp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_incorrectly_formatted_mac_unique_id_added_from_dhcp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test incorrectly formatted mac is updated and unique id added.""" - entry_data = MOCK_OLD_ENTRY.copy() + entry_data = ENTRYDATA_LEGACY.copy() entry_data[CONF_MAC] = "aabbccddeeff" entry = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id=None) entry.add_to_hass(hass) @@ -1399,13 +1352,17 @@ async def test_update_incorrectly_formatted_mac_unique_id_added_from_dhcp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_added_from_zeroconf( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac and unique id added.""" entry = MockConfigEntry( - domain=DOMAIN, data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, unique_id=None + domain=DOMAIN, + data={**ENTRYDATA_LEGACY, "host": "127.0.0.1"}, + unique_id=None, ) entry.add_to_hass(hass) @@ -1423,14 +1380,14 @@ async def test_update_missing_mac_unique_id_added_from_zeroconf( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_update_missing_model_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing model added via ssdp on legacy models.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_OLD_ENTRY, + data=ENTRYDATA_LEGACY, unique_id=None, ) entry.add_to_hass(hass) @@ -1445,15 +1402,17 @@ async def test_update_missing_model_added_from_ssdp( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert entry.data[CONF_MODEL] == "fake_model" + assert entry.data[CONF_MODEL] == "UE55H6400" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_ssdp_location_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac, ssdp_location, and unique id added via ssdp.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_LEGACY, unique_id=None) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -1473,7 +1432,10 @@ async def test_update_missing_mac_unique_id_ssdp_location_added_from_ssdp( @pytest.mark.usefixtures( - "remote", "remotews", "remoteencws_failing", "rest_api_failing" + "remote_legacy", + "remote_websocket", + "remote_encrypted_websocket_failing", + "rest_api_failing", ) async def test_update_zeroconf_discovery_preserved_unique_id( hass: HomeAssistant, @@ -1481,7 +1443,7 @@ async def test_update_zeroconf_discovery_preserved_unique_id( """Test zeroconf discovery preserves unique id.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:zz:ee:rr:oo"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:zz:ee:rr:oo"}, unique_id="original", ) entry.add_to_hass(hass) @@ -1492,12 +1454,14 @@ async def test_update_zeroconf_discovery_preserved_unique_id( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED assert entry.data[CONF_MAC] == "aa:bb:zz:ee:rr:oo" assert entry.unique_id == "original" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_added_ssdp_location_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: @@ -1505,7 +1469,7 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_updated_from_ssd entry = MockConfigEntry( domain=DOMAIN, data={ - **MOCK_OLD_ENTRY, + **ENTRYDATA_LEGACY, CONF_SSDP_RENDERING_CONTROL_LOCATION: "https://1.2.3.4:555/test", }, unique_id=None, @@ -1530,7 +1494,9 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_updated_from_ssd assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_added_ssdp_location_rendering_st_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: @@ -1538,7 +1504,7 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_rendering_st_upd entry = MockConfigEntry( domain=DOMAIN, data={ - **MOCK_OLD_ENTRY, + **ENTRYDATA_LEGACY, CONF_SSDP_RENDERING_CONTROL_LOCATION: "https://1.2.3.4:555/test", }, unique_id=None, @@ -1559,12 +1525,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_rendering_st_upd # Correct ST, ssdp location should change assert ( entry.data[CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: @@ -1572,7 +1540,7 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st entry = MockConfigEntry( domain=DOMAIN, data={ - **MOCK_OLD_ENTRY, + **ENTRYDATA_LEGACY, CONF_SSDP_RENDERING_CONTROL_LOCATION: "https://1.2.3.4:555/test", CONF_SSDP_MAIN_TV_AGENT_LOCATION: "https://1.2.3.4:555/test", }, @@ -1593,8 +1561,7 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st assert entry.data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" # Main TV Agent ST, ssdp location should change assert ( - entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] - == "https://fake_host:12345/tv_agent" + entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "http://10.10.12.34:7676/smp_2_" ) # Rendering control should not be affected assert ( @@ -1603,14 +1570,16 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_ssdp_location_rendering_st_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test with outdated ssdp_location with the correct st added via ssdp.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) @@ -1629,19 +1598,21 @@ async def test_update_ssdp_location_rendering_st_updated_from_ssdp( # Correct ST, ssdp location should be added assert ( entry.data[CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_main_tv_ssdp_location_rendering_st_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test with outdated ssdp_location with the correct st added via ssdp.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) @@ -1659,20 +1630,19 @@ async def test_update_main_tv_ssdp_location_rendering_st_updated_from_ssdp( assert entry.data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" # Correct ST for MainTV, ssdp location should be added assert ( - entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] - == "https://fake_host:12345/tv_agent" + entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "http://10.10.12.34:7676/smp_2_" ) assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac and unique id added.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, + data={**ENTRYDATA_LEGACY, "host": "127.0.0.1"}, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) @@ -1691,14 +1661,14 @@ async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_legacy_missing_mac_from_dhcp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac added.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_LEGACY_ENTRY, + data=ENTRYDATA_LEGACY, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) @@ -1707,7 +1677,7 @@ async def test_update_legacy_missing_mac_from_dhcp( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" + ip="10.10.12.34", macaddress="aabbccddeeff", hostname="fake_hostname" ), ) await hass.async_block_till_done() @@ -1719,7 +1689,7 @@ async def test_update_legacy_missing_mac_from_dhcp( assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( hass: HomeAssistant, rest_api: Mock, mock_setup_entry: AsyncMock ) -> None: @@ -1727,7 +1697,7 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( rest_api.rest_device_info.side_effect = HttpApiError entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_LEGACY_ENTRY, + data=ENTRYDATA_LEGACY, ) entry.add_to_hass(hass) with ( @@ -1744,26 +1714,28 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" + ip="10.10.12.34", macaddress="aabbccddeeff", hostname="fake_hostname" ), ) await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" assert entry.unique_id is None -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_ssdp_location_unique_id_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing ssdp_location, and unique id added via ssdp.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id=None, ) entry.add_to_hass(hass) @@ -1784,14 +1756,16 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_ssdp_location_unique_id_added_from_ssdp_with_rendering_control_st( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing ssdp_location, and unique id added via ssdp with rendering control st.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id=None, ) entry.add_to_hass(hass) @@ -1810,15 +1784,15 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp_with_rendering_con # Correct st assert ( entry.data[CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_form_reauth_legacy(hass: HomeAssistant) -> None: """Test reauthenticate legacy.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_LEGACY) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM @@ -1833,10 +1807,10 @@ async def test_form_reauth_legacy(hass: HomeAssistant) -> None: assert result2["reason"] == "reauth_successful" -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_form_reauth_websocket(hass: HomeAssistant) -> None: """Test reauthenticate websocket.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRYDATA_WS) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_WEBSOCKET) entry.add_to_hass(hass) assert entry.state is ConfigEntryState.NOT_LOADED @@ -1856,16 +1830,16 @@ async def test_form_reauth_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") async def test_form_reauth_websocket_cannot_connect( - hass: HomeAssistant, remotews: Mock + hass: HomeAssistant, remote_websocket: Mock ) -> None: """Test reauthenticate websocket when we cannot connect on the first attempt.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRYDATA_WS) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_WEBSOCKET) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch.object(remotews, "open", side_effect=ConnectionFailure): + with patch.object(remote_websocket, "open", side_effect=ConnectionFailure): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {}, @@ -1887,7 +1861,7 @@ async def test_form_reauth_websocket_cannot_connect( async def test_form_reauth_websocket_not_supported(hass: HomeAssistant) -> None: """Test reauthenticate websocket when the device is not supported.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRYDATA_WS) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_WEBSOCKET) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM @@ -1904,13 +1878,13 @@ async def test_form_reauth_websocket_not_supported(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "not_supported" + assert result2["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_form_reauth_encrypted(hass: HomeAssistant) -> None: """Test reauth flow for encrypted TVs.""" - encrypted_entry_data = {**MOCK_ENTRYDATA_ENCRYPTED_WS} + encrypted_entry_data = deepcopy(ENTRYDATA_ENCRYPTED_WEBSOCKET) del encrypted_entry_data[CONF_TOKEN] del encrypted_entry_data[CONF_SESSION_ID] @@ -1963,7 +1937,7 @@ async def test_form_reauth_encrypted(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED authenticator_mock.assert_called_once() - assert authenticator_mock.call_args[0] == ("fake_host",) + assert authenticator_mock.call_args[0] == ("10.10.12.34",) authenticator_mock.return_value.start_pairing.assert_called_once() assert authenticator_mock.return_value.try_pin.call_count == 2 @@ -1977,15 +1951,17 @@ async def test_form_reauth_encrypted(hass: HomeAssistant) -> None: assert entry.data[CONF_SESSION_ID] == "1" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_incorrect_udn_matching_upnp_udn_unique_id_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test updating the wrong udn from ssdp via upnp udn match.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_OLD_ENTRY, - unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", + data=ENTRYDATA_LEGACY, + unique_id="068e7781-006e-1000-bbbf-84a4668d8423", ) entry.add_to_hass(hass) @@ -2003,14 +1979,16 @@ async def test_update_incorrect_udn_matching_upnp_udn_unique_id_added_from_ssdp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_incorrect_udn_matching_mac_unique_id_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test updating the wrong udn from ssdp via mac match.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id=None, ) entry.add_to_hass(hass) @@ -2029,19 +2007,25 @@ async def test_update_incorrect_udn_matching_mac_unique_id_added_from_ssdp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket") async def test_update_incorrect_udn_matching_mac_from_dhcp( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, rest_api: Mock, mock_setup_entry: AsyncMock ) -> None: """Test that DHCP updates the wrong udn from ssdp via mac match.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_ENTRYDATA_WS, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_WEBSOCKET, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, source=config_entries.SOURCE_SSDP, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) + assert entry.data[CONF_HOST] == MOCK_DHCP_DATA.ip + assert entry.data[CONF_MAC] == dr.format_mac( + rest_api.rest_device_info.return_value["device"]["wifiMac"] + ) + assert entry.unique_id != _strip_uuid(rest_api.rest_device_info.return_value["id"]) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -2052,23 +2036,30 @@ async def test_update_incorrect_udn_matching_mac_from_dhcp( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert entry.data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" + + # Same IP + same MAC => unique id updated assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket") async def test_no_update_incorrect_udn_not_matching_mac_from_dhcp( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, rest_api: Mock, mock_setup_entry: AsyncMock ) -> None: """Test that DHCP does not update the wrong udn from ssdp via host match.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_ENTRYDATA_WS, CONF_MAC: "aa:bb:ss:ss:dd:pp"}, + data={**ENTRYDATA_WEBSOCKET, CONF_MAC: "aa:bb:ss:ss:dd:pp"}, source=config_entries.SOURCE_SSDP, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) + assert entry.data[CONF_HOST] == MOCK_DHCP_DATA.ip + assert entry.data[CONF_MAC] != dr.format_mac( + rest_api.rest_device_info.return_value["device"]["wifiMac"] + ) + assert entry.unique_id != _strip_uuid(rest_api.rest_device_info.return_value["id"]) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -2079,11 +2070,12 @@ async def test_no_update_incorrect_udn_not_matching_mac_from_dhcp( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" - assert entry.data[CONF_MAC] == "aa:bb:ss:ss:dd:pp" + + # Same IP + different MAC => unique id not updated assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" -@pytest.mark.usefixtures("remotews", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket", "remote_encrypted_websocket_failing") async def test_ssdp_update_mac(hass: HomeAssistant) -> None: """Ensure that MAC address is correctly updated from SSDP.""" with patch( diff --git a/tests/components/samsungtv/test_device_trigger.py b/tests/components/samsungtv/test_device_trigger.py index e67f154cae1..adb80293744 100644 --- a/tests/components/samsungtv/test_device_trigger.py +++ b/tests/components/samsungtv/test_device_trigger.py @@ -16,17 +16,17 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry -from .const import MOCK_ENTRYDATA_ENCRYPTED_WS +from .const import ENTRYDATA_ENCRYPTED_WEBSOCKET from tests.common import MockConfigEntry, async_get_device_automations -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_get_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test we get the expected triggers.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) device = device_registry.async_get_device( identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} @@ -46,15 +46,15 @@ async def test_get_triggers( assert turn_on_trigger in triggers -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_if_fires_on_turn_on_request( hass: HomeAssistant, device_registry: dr.DeviceRegistry, service_calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - entity_id = "media_player.fake" + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) + entity_id = "media_player.mock_title" device = device_registry.async_get_device( identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} @@ -109,12 +109,12 @@ async def test_if_fires_on_turn_on_request( assert service_calls[2].data["id"] == 0 -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_failure_scenarios( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test failure scenarios.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) # Test wrong trigger platform type with pytest.raises(HomeAssistantError): diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 53d52456de5..1704b0c0422 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -4,138 +4,63 @@ from unittest.mock import Mock import pytest from samsungtvws.exceptions import HttpApiError +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.samsungtv.const import DOMAIN from homeassistant.core import HomeAssistant from . import setup_samsungtv_entry -from .const import ( - MOCK_ENTRY_WS_WITH_MAC, - MOCK_ENTRYDATA_ENCRYPTED_WS, - SAMPLE_DEVICE_INFO_UE48JU6400, - SAMPLE_DEVICE_INFO_WIFI, -) +from .const import ENTRYDATA_ENCRYPTED_WEBSOCKET, ENTRYDATA_WEBSOCKET -from tests.common import ANY +from tests.common import load_json_object_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - config_entry = await setup_samsungtv_entry(hass, MOCK_ENTRY_WS_WITH_MAC) + config_entry = await setup_samsungtv_entry(hass, ENTRYDATA_WEBSOCKET) - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "created_at": ANY, - "data": { - "host": "fake_host", - "ip_address": "test", - "mac": "aa:bb:cc:dd:ee:ff", - "method": "websocket", - "model": "82GXARRS", - "name": "fake", - "port": 8002, - "token": REDACTED, - }, - "disabled_by": None, - "discovery_keys": {}, - "domain": "samsungtv", - "entry_id": "123456", - "minor_version": 2, - "modified_at": ANY, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "subentries": [], - "title": "Mock Title", - "unique_id": "be9554b9-c9fb-41f4-8920-22da015376a4", - "version": 2, - }, - "device_info": SAMPLE_DEVICE_INFO_WIFI, - } + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) -@pytest.mark.usefixtures("remoteencws") +@pytest.mark.usefixtures("remote_encrypted_websocket") async def test_entry_diagnostics_encrypted( - hass: HomeAssistant, rest_api: Mock, hass_client: ClientSessionGenerator + hass: HomeAssistant, + rest_api: Mock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_UE48JU6400 - config_entry = await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + rest_api.rest_device_info.return_value = load_json_object_fixture( + "device_info_UE48JU6400.json", DOMAIN + ) + config_entry = await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "created_at": ANY, - "data": { - "host": "fake_host", - "ip_address": "test", - "mac": "aa:bb:cc:dd:ee:ff", - "method": "encrypted", - "model": "UE48JU6400", - "name": "fake", - "port": 8000, - "token": REDACTED, - "session_id": REDACTED, - }, - "disabled_by": None, - "discovery_keys": {}, - "domain": "samsungtv", - "entry_id": "123456", - "minor_version": 2, - "modified_at": ANY, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "subentries": [], - "title": "Mock Title", - "unique_id": "be9554b9-c9fb-41f4-8920-22da015376a4", - "version": 2, - }, - "device_info": SAMPLE_DEVICE_INFO_UE48JU6400, - } + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) -@pytest.mark.usefixtures("remoteencws") +@pytest.mark.usefixtures("remote_encrypted_websocket") async def test_entry_diagnostics_encrypte_offline( - hass: HomeAssistant, rest_api: Mock, hass_client: ClientSessionGenerator + hass: HomeAssistant, + rest_api: Mock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" rest_api.rest_device_info.side_effect = HttpApiError - config_entry = await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + config_entry = await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "created_at": ANY, - "data": { - "host": "fake_host", - "ip_address": "test", - "mac": "aa:bb:cc:dd:ee:ff", - "method": "encrypted", - "name": "fake", - "port": 8000, - "token": REDACTED, - "session_id": REDACTED, - }, - "disabled_by": None, - "discovery_keys": {}, - "domain": "samsungtv", - "entry_id": "123456", - "minor_version": 2, - "modified_at": ANY, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "subentries": [], - "title": "Mock Title", - "unique_id": "be9554b9-c9fb-41f4-8920-22da015376a4", - "version": 2, - }, - "device_info": None, - } + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 9f1efc0f013..74af1b72c1c 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -1,140 +1,93 @@ """Tests for the Samsung TV Integration.""" -from unittest.mock import AsyncMock, Mock, patch +from typing import Any +from unittest.mock import Mock, patch import pytest -from samsungtvws.async_remote import SamsungTVWSAsyncRemote from syrupy.assertion import SnapshotAssertion -from homeassistant.components.media_player import ( - DOMAIN as MP_DOMAIN, - MediaPlayerEntityFeature, -) from homeassistant.components.samsungtv.const import ( - CONF_MANUFACTURER, CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, - LEGACY_PORT, + METHOD_ENCRYPTED_WEBSOCKET, METHOD_LEGACY, METHOD_WEBSOCKET, UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) -from homeassistant.components.samsungtv.media_player import SUPPORT_SAMSUNGTV from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - CONF_HOST, - CONF_MAC, - CONF_METHOD, - CONF_NAME, - CONF_PORT, - CONF_TOKEN, - SERVICE_VOLUME_UP, -) +from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from . import setup_samsungtv_entry from .const import ( - MOCK_ENTRY_WS_WITH_MAC, - MOCK_ENTRYDATA_ENCRYPTED_WS, - MOCK_ENTRYDATA_WS, + ENTRYDATA_ENCRYPTED_WEBSOCKET, + ENTRYDATA_LEGACY, + ENTRYDATA_WEBSOCKET, MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - SAMPLE_DEVICE_INFO_UE48JU6400, ) -from tests.common import MockConfigEntry - -ENTITY_ID = f"{MP_DOMAIN}.fake_name" -MOCK_CONFIG = { - CONF_HOST: "fake_host", - CONF_NAME: "fake_name", - CONF_METHOD: METHOD_WEBSOCKET, -} +from tests.common import MockConfigEntry, load_json_object_fixture -@pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup(hass: HomeAssistant) -> None: - """Test Samsung TV integration is setup.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - state = hass.states.get(ENTITY_ID) +@pytest.mark.parametrize( + "entry_data", + [ENTRYDATA_LEGACY, ENTRYDATA_ENCRYPTED_WEBSOCKET, ENTRYDATA_WEBSOCKET], + ids=[METHOD_LEGACY, METHOD_ENCRYPTED_WEBSOCKET, METHOD_WEBSOCKET], +) +@pytest.mark.usefixtures( + "remote_encrypted_websocket", + "remote_legacy", + "remote_websocket", + "rest_api_failing", +) +async def test_setup( + hass: HomeAssistant, + entry_data: dict[str, Any], + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test Samsung TV integration loads and fill device registry.""" + entry = await setup_samsungtv_entry(hass, entry_data) - # test name and turn_on - assert state - assert state.name == "fake_name" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == SUPPORT_SAMSUNGTV | MediaPlayerEntityFeature.TURN_ON - ) + assert entry.state is ConfigEntryState.LOADED - # Ensure service is registered - await hass.services.async_call( - MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True - ) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert device_entries == snapshot -async def test_setup_without_port_device_offline(hass: HomeAssistant) -> None: - """Test import from yaml when the device is offline.""" - with ( - patch("homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError), - patch( - "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", - side_effect=OSError, - ), - patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", - side_effect=OSError, - ), - patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", - return_value=None, - ), - ): - await setup_samsungtv_entry(hass, MOCK_CONFIG) - - config_entries_domain = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries_domain) == 1 - assert config_entries_domain[0].state is ConfigEntryState.SETUP_RETRY - - -@pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_without_port_device_online(hass: HomeAssistant) -> None: - """Test import from yaml when the device is online.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - - config_entries_domain = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries_domain) == 1 - assert config_entries_domain[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" - - -@pytest.mark.usefixtures("remotews", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket") async def test_setup_h_j_model( hass: HomeAssistant, rest_api: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test Samsung TV integration is setup.""" - rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_UE48JU6400 - await setup_samsungtv_entry(hass, MOCK_CONFIG) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state + rest_api.rest_device_info.return_value = load_json_object_fixture( + "device_info_UE48JU6400.json", DOMAIN + ) + entry = await setup_samsungtv_entry( + hass, {**ENTRYDATA_WEBSOCKET, CONF_MODEL: "UE48JU6400"} + ) + + assert entry.state is ConfigEntryState.LOADED + assert "H and J series use an encrypted protocol" in caplog.text -@pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_updates_from_ssdp( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion -) -> None: +@pytest.mark.usefixtures("remote_websocket") +async def test_setup_updates_from_ssdp(hass: HomeAssistant) -> None: """Test setting up the entry fetches data from ssdp cache.""" entry = MockConfigEntry( - domain="samsungtv", data=MOCK_ENTRYDATA_WS, entry_id="sample-entry-id" + domain="samsungtv", data=ENTRYDATA_WEBSOCKET, entry_id="sample-entry-id" ) entry.add_to_hass(hass) + assert not entry.data.get(CONF_SSDP_MAIN_TV_AGENT_LOCATION) + assert not entry.data.get(CONF_SSDP_RENDERING_CONTROL_LOCATION) + async def _mock_async_get_discovery_info_by_st(hass: HomeAssistant, mock_st: str): if mock_st == UPNP_SVC_RENDERING_CONTROL: return [MOCK_SSDP_DATA_RENDERING_CONTROL_ST] @@ -147,25 +100,20 @@ async def test_setup_updates_from_ssdp( _mock_async_get_discovery_info_by_st, ): await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - assert hass.states.get("media_player.any") == snapshot - assert entity_registry.async_get("media_player.any") == snapshot assert ( - entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] - == "https://fake_host:12345/tv_agent" + entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "http://10.10.12.34:7676/smp_2_" ) assert ( entry.data[CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_reauth_triggered_encrypted(hass: HomeAssistant) -> None: """Test reauth flow is triggered for encrypted TVs.""" - encrypted_entry_data = {**MOCK_ENTRYDATA_ENCRYPTED_WS} + encrypted_entry_data = {**ENTRYDATA_ENCRYPTED_WEBSOCKET} del encrypted_entry_data[CONF_TOKEN] del encrypted_entry_data[CONF_SESSION_ID] @@ -179,95 +127,16 @@ async def test_reauth_triggered_encrypted(hass: HomeAssistant) -> None: assert len(flows_in_progress) == 1 -@pytest.mark.usefixtures("remote", "remotews", "rest_api_failing") -async def test_update_imported_legacy_without_method(hass: HomeAssistant) -> None: - """Test updating an imported legacy entry without a method.""" - await setup_samsungtv_entry( - hass, {CONF_HOST: "fake_host", CONF_MANUFACTURER: "Samsung"} - ) - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].data[CONF_METHOD] == METHOD_LEGACY - assert entries[0].data[CONF_PORT] == LEGACY_PORT - - -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_incorrectly_formatted_mac_fixed(hass: HomeAssistant) -> None: """Test incorrectly formatted mac is corrected.""" - with patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" - ) as remote_class: - remote = Mock(SamsungTVWSAsyncRemote) - remote.__aenter__ = AsyncMock(return_value=remote) - remote.__aexit__ = AsyncMock() - remote.token = "123456789" - remote_class.return_value = remote - - await setup_samsungtv_entry( - hass, - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 8001, - CONF_TOKEN: "123456789", - CONF_METHOD: METHOD_WEBSOCKET, - CONF_MAC: "aabbaaaaaaaa", - }, - ) - await hass.async_block_till_done() - - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - assert config_entries[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" - - -@pytest.mark.usefixtures("remotews", "rest_api") -@pytest.mark.xfail -async def test_cleanup_mac( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion -) -> None: - """Test for `none` mac cleanup #103512. - - Reverted due to device registry collisions in #119249 / #119082 - """ - entry = MockConfigEntry( - domain=DOMAIN, - data=MOCK_ENTRY_WS_WITH_MAC, - entry_id="123456", - unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", - version=2, - minor_version=1, + # Incorrect MAC cleanup introduced in #110599, can be removed in 2026.3 + await setup_samsungtv_entry( + hass, + {**ENTRYDATA_WEBSOCKET, CONF_MAC: "aabbaaaaaaaa"}, ) - entry.add_to_hass(hass) - - # Setup initial device registry, with incorrect MAC - device_registry.async_get_or_create( - config_entry_id="123456", - connections={ - (dr.CONNECTION_NETWORK_MAC, "none"), - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), - }, - identifiers={("samsungtv", "be9554b9-c9fb-41f4-8920-22da015376a4")}, - model="82GXARRS", - name="fake", - ) - device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) - assert device_entries == snapshot - assert device_entries[0].connections == { - (dr.CONNECTION_NETWORK_MAC, "none"), - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), - } - - # Run setup, and ensure the NONE mac is removed - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) - assert device_entries == snapshot - assert device_entries[0].connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") - } - - assert entry.version == 2 - assert entry.minor_version == 2 + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 10e5249aac3..1bf3c953fc6 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -53,7 +53,6 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, CONF_HOST, - CONF_IP_ADDRESS, CONF_MAC, CONF_METHOD, CONF_MODEL, @@ -77,22 +76,24 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotSupported +from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry from .const import ( - MOCK_CONFIG, - MOCK_ENTRY_WS_WITH_MAC, - MOCK_ENTRYDATA_ENCRYPTED_WS, - SAMPLE_DEVICE_INFO_FRAME, + ENTRYDATA_ENCRYPTED_WEBSOCKET, + ENTRYDATA_LEGACY, + ENTRYDATA_WEBSOCKET, SAMPLE_DEVICE_INFO_WIFI, - SAMPLE_EVENT_ED_INSTALLED_APP, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) -ENTITY_ID = f"{MP_DOMAIN}.fake" +ENTITY_ID = f"{MP_DOMAIN}.mock_title" MOCK_CONFIGWS = { CONF_HOST: "fake_host", CONF_NAME: "fake", @@ -109,7 +110,6 @@ MOCK_CALLS_WS = { } MOCK_ENTRY_WS = { - CONF_IP_ADDRESS: "test", CONF_HOST: "fake_host", CONF_METHOD: "websocket", CONF_NAME: "fake", @@ -119,14 +119,14 @@ MOCK_ENTRY_WS = { } -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_setup(hass: HomeAssistant) -> None: """Test setup of platform.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) assert hass.states.get(ENTITY_ID) -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_setup_websocket(hass: HomeAssistant) -> None: """Test setup of platform.""" with patch( @@ -156,12 +156,9 @@ async def test_setup_websocket_2( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test setup of platform from config entry.""" - entity_id = f"{MP_DOMAIN}.fake" - entry = MockConfigEntry( domain=DOMAIN, data=MOCK_ENTRY_WS, - unique_id=entity_id, ) entry.add_to_hass(hass) @@ -186,7 +183,7 @@ async def test_setup_websocket_2( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(entity_id) + state = hass.states.get(ENTITY_ID) assert state remote_class.assert_called_once_with(**MOCK_CALLS_WS) @@ -204,7 +201,7 @@ async def test_setup_encrypted_websocket( remote.__aexit__ = AsyncMock() remote_class.return_value = remote - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -215,10 +212,10 @@ async def test_setup_encrypted_websocket( remote_class.assert_called_once() -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_on(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Testing update tv on.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -228,10 +225,10 @@ async def test_update_on(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> assert state.state == STATE_ON -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Testing update tv off.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -246,7 +243,10 @@ async def test_update_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) - async def test_update_off_ws_no_power_state( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock, rest_api: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remote_websocket: Mock, + rest_api: Mock, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -257,8 +257,8 @@ async def test_update_off_ws_no_power_state( state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - remotews.start_listening = Mock(side_effect=WebSocketException("Boom")) - remotews.is_alive.return_value = False + remote_websocket.start_listening = Mock(side_effect=WebSocketException("Boom")) + remote_websocket.is_alive.return_value = False freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -269,9 +269,12 @@ async def test_update_off_ws_no_power_state( rest_api.rest_device_info.assert_not_called() -@pytest.mark.usefixtures("remotews") +@pytest.mark.usefixtures("remote_websocket") async def test_update_off_ws_with_power_state( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock, rest_api: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remote_websocket: Mock, + rest_api: Mock, ) -> None: """Testing update tv off.""" with ( @@ -279,7 +282,7 @@ async def test_update_off_ws_with_power_state( rest_api, "rest_device_info", side_effect=HttpApiError ) as mock_device_info, patch.object( - remotews, "start_listening", side_effect=WebSocketException("Boom") + remote_websocket, "start_listening", side_effect=WebSocketException("Boom") ) as mock_start_listening, ): await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -299,14 +302,14 @@ async def test_update_off_ws_with_power_state( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - remotews.start_listening.assert_called_once() + remote_websocket.start_listening.assert_called_once() rest_api.rest_device_info.assert_called_once() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON # After initial update, start_listening shouldn't be called - remotews.start_listening.reset_mock() + remote_websocket.start_listening.reset_mock() # Second update uses device_info(ON) rest_api.rest_device_info.reset_mock() @@ -333,25 +336,27 @@ async def test_update_off_ws_with_power_state( state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE - remotews.start_listening.assert_not_called() + remote_websocket.start_listening.assert_not_called() async def test_update_off_encryptedws( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - remoteencws: Mock, + remote_encrypted_websocket: Mock, rest_api: Mock, ) -> None: """Testing update tv off.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) rest_api.rest_device_info.assert_called_once() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - remoteencws.start_listening = Mock(side_effect=WebSocketException("Boom")) - remoteencws.is_alive.return_value = False + remote_encrypted_websocket.start_listening = Mock( + side_effect=WebSocketException("Boom") + ) + remote_encrypted_websocket.is_alive.return_value = False freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -362,12 +367,12 @@ async def test_update_off_encryptedws( rest_api.rest_device_info.assert_called_once() -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_access_denied( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv access denied exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -394,7 +399,7 @@ async def test_update_access_denied( async def test_update_ws_connection_failure( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - remotews: Mock, + remote_websocket: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Testing update tv connection failure exception.""" @@ -402,11 +407,11 @@ async def test_update_ws_connection_failure( with ( patch.object( - remotews, + remote_websocket, "start_listening", - side_effect=ConnectionFailure('{"event": "ms.voiceApp.hide"}'), + side_effect=ConnectionFailure({"event": "ms.voiceApp.hide"}), ), - patch.object(remotews, "is_alive", return_value=False), + patch.object(remote_websocket, "is_alive", return_value=False), ): freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -414,7 +419,7 @@ async def test_update_ws_connection_failure( assert ( "Unexpected ConnectionFailure trying to get remote for fake_host, please " - 'report this issue: ConnectionFailure(\'{"event": "ms.voiceApp.hide"}\')' + "report this issue: ConnectionFailure({'event': 'ms.voiceApp.hide'})" in caplog.text ) @@ -423,17 +428,50 @@ async def test_update_ws_connection_failure( @pytest.mark.usefixtures("rest_api") -async def test_update_ws_connection_closed( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock +async def test_update_ws_connection_failure_channel_timeout( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remote_websocket: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: """Testing update tv connection failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with ( patch.object( - remotews, "start_listening", side_effect=ConnectionClosedError(None, None) + remote_websocket, + "start_listening", + side_effect=ConnectionFailure({"event": "ms.channel.timeOut"}), ), - patch.object(remotews, "is_alive", return_value=False), + patch.object(remote_websocket, "is_alive", return_value=False), + ): + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + "Channel timeout occurred trying to get remote for fake_host: " + "ConnectionFailure({'event': 'ms.channel.timeOut'})" in caplog.text + ) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + + +@pytest.mark.usefixtures("rest_api") +async def test_update_ws_connection_closed( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remote_websocket: Mock +) -> None: + """Testing update tv connection failure exception.""" + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) + + with ( + patch.object( + remote_websocket, + "start_listening", + side_effect=ConnectionClosedError(None, None), + ), + patch.object(remote_websocket, "is_alive", return_value=False), ): freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -445,14 +483,16 @@ async def test_update_ws_connection_closed( @pytest.mark.usefixtures("rest_api") async def test_update_ws_unauthorized_error( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remote_websocket: Mock ) -> None: """Testing update tv unauthorized failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with ( - patch.object(remotews, "start_listening", side_effect=UnauthorizedError), - patch.object(remotews, "is_alive", return_value=False), + patch.object( + remote_websocket, "start_listening", side_effect=UnauthorizedError + ), + patch.object(remote_websocket, "is_alive", return_value=False), ): freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -467,12 +507,12 @@ async def test_update_ws_unauthorized_error( assert state.state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_unhandled_response( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv unhandled response exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -486,12 +526,12 @@ async def test_update_unhandled_response( assert state.state == STATE_ON -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_connection_closed_during_update_can_recover( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv connection closed exception can recover.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -512,23 +552,23 @@ async def test_connection_closed_during_update_can_recover( assert state.state == STATE_ON -async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: +async def test_send_key(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for send key.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_VOLUP")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_VOLUP")] assert state.state == STATE_ON -async def test_send_key_broken_pipe(hass: HomeAssistant, remote: Mock) -> None: +async def test_send_key_broken_pipe(hass: HomeAssistant, remote_legacy: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.control = Mock(side_effect=BrokenPipeError("Boom")) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.control = Mock(side_effect=BrokenPipeError("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -537,11 +577,11 @@ async def test_send_key_broken_pipe(hass: HomeAssistant, remote: Mock) -> None: async def test_send_key_connection_closed_retry_succeed( - hass: HomeAssistant, remote: Mock + hass: HomeAssistant, remote_legacy: Mock ) -> None: """Test retry on connection closed.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.control = Mock( + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.control = Mock( side_effect=[exceptions.ConnectionClosed("Boom"), DEFAULT_MOCK, DEFAULT_MOCK] ) await hass.services.async_call( @@ -549,30 +589,36 @@ async def test_send_key_connection_closed_retry_succeed( ) state = hass.states.get(ENTITY_ID) # key because of retry two times - assert remote.control.call_count == 2 - assert remote.control.call_args_list == [ + assert remote_legacy.control.call_count == 2 + assert remote_legacy.control.call_args_list == [ call("KEY_VOLUP"), call("KEY_VOLUP"), ] assert state.state == STATE_ON -async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> None: +async def test_send_key_unhandled_response( + hass: HomeAssistant, remote_legacy: Mock +) -> None: """Testing unhandled response exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.control = Mock(side_effect=exceptions.UnhandledResponse("Boom")) - await hass.services.async_call( - MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True - ) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.control = Mock(side_effect=exceptions.UnhandledResponse("Boom")) + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + assert err.value.translation_key == "error_sending_command" state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @pytest.mark.usefixtures("rest_api") -async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) -> None: +async def test_send_key_websocketexception( + hass: HomeAssistant, remote_websocket: Mock +) -> None: """Testing unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands = Mock(side_effect=WebSocketException("Boom")) + remote_websocket.send_commands = Mock(side_effect=WebSocketException("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -582,11 +628,13 @@ async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) @pytest.mark.usefixtures("rest_api") async def test_send_key_websocketexception_encrypted( - hass: HomeAssistant, remoteencws: Mock + hass: HomeAssistant, remote_encrypted_websocket: Mock ) -> None: """Testing unhandled response exception.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - remoteencws.send_commands = Mock(side_effect=WebSocketException("Boom")) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) + remote_encrypted_websocket.send_commands = Mock( + side_effect=WebSocketException("Boom") + ) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -595,10 +643,12 @@ async def test_send_key_websocketexception_encrypted( @pytest.mark.usefixtures("rest_api") -async def test_send_key_os_error_ws(hass: HomeAssistant, remotews: Mock) -> None: +async def test_send_key_os_error_ws( + hass: HomeAssistant, remote_websocket: Mock +) -> None: """Testing unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands = Mock(side_effect=OSError("Boom")) + remote_websocket.send_commands = Mock(side_effect=OSError("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -608,11 +658,11 @@ async def test_send_key_os_error_ws(hass: HomeAssistant, remotews: Mock) -> None @pytest.mark.usefixtures("rest_api") async def test_send_key_os_error_ws_encrypted( - hass: HomeAssistant, remoteencws: Mock + hass: HomeAssistant, remote_encrypted_websocket: Mock ) -> None: """Testing unhandled response exception.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - remoteencws.send_commands = Mock(side_effect=OSError("Boom")) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) + remote_encrypted_websocket.send_commands = Mock(side_effect=OSError("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -620,10 +670,10 @@ async def test_send_key_os_error_ws_encrypted( assert state.state == STATE_ON -async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: +async def test_send_key_os_error(hass: HomeAssistant, remote_legacy: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.control = Mock(side_effect=OSError("Boom")) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.control = Mock(side_effect=OSError("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -631,18 +681,18 @@ async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: assert state.state == STATE_ON -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_name(hass: HomeAssistant) -> None: """Test for name property.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake" + assert state.attributes[ATTR_FRIENDLY_NAME] == "Mock Title" -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test for state property.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -668,48 +718,50 @@ async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> Non assert state.state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_supported_features(hass: HomeAssistant) -> None: """Test for supported_features property.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SAMSUNGTV -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_device_class(hass: HomeAssistant) -> None: """Test for device_class property.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_DEVICE_CLASS] == MediaPlayerDeviceClass.TV @pytest.mark.usefixtures("rest_api") async def test_turn_off_websocket( - hass: HomeAssistant, remotews: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, remote_websocket: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test for turn_off.""" - remotews.app_list_data = SAMPLE_EVENT_ED_INSTALLED_APP + remote_websocket.app_list_data = load_json_object_fixture( + "ws_installed_app_event.json", DOMAIN + ) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(commands[0], SendRemoteKey) assert commands[0].params["DataOfCmd"] == "KEY_POWER" # commands not sent : power off in progress - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -721,28 +773,30 @@ async def test_turn_off_websocket( True, ) assert "TV is powering off, not sending launch_app command" in caplog.text - remotews.send_commands.assert_not_called() + remote_websocket.send_commands.assert_not_called() async def test_turn_off_websocket_frame( - hass: HomeAssistant, remotews: Mock, rest_api: Mock + hass: HomeAssistant, remote_websocket: Mock, rest_api: Mock ) -> None: """Test for turn_off.""" - rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_FRAME + rest_api.rest_device_info.return_value = load_json_object_fixture( + "device_info_UE43LS003.json", DOMAIN + ) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 3 assert isinstance(commands[0], SendRemoteKey) assert commands[0].params["Cmd"] == "Press" @@ -755,36 +809,38 @@ async def test_turn_off_websocket_frame( async def test_turn_off_encrypted_websocket( - hass: HomeAssistant, remoteencws: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + remote_encrypted_websocket: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for turn_off.""" - entry_data = deepcopy(MOCK_ENTRYDATA_ENCRYPTED_WS) + entry_data = deepcopy(ENTRYDATA_ENCRYPTED_WEBSOCKET) entry_data[CONF_MODEL] = "UE48UNKNOWN" await setup_samsungtv_entry(hass, entry_data) - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() caplog.clear() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remoteencws.send_commands.call_count == 1 - commands = remoteencws.send_commands.call_args_list[0].args[0] + assert remote_encrypted_websocket.send_commands.call_count == 1 + commands = remote_encrypted_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 2 assert isinstance(command := commands[0], SamsungTVEncryptedCommand) assert command.body["param3"] == "KEY_POWEROFF" assert isinstance(command := commands[1], SamsungTVEncryptedCommand) assert command.body["param3"] == "KEY_POWER" - assert "Unknown power_off command for UE48UNKNOWN (fake_host)" in caplog.text + assert "Unknown power_off command for UE48UNKNOWN (10.10.12.34)" in caplog.text # commands not sent : power off in progress - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert "TV is powering off, not sending keys: ['KEY_VOLUP']" in caplog.text - remoteencws.send_commands.assert_not_called() + remote_encrypted_websocket.send_commands.assert_not_called() @pytest.mark.parametrize( @@ -793,49 +849,49 @@ async def test_turn_off_encrypted_websocket( ) async def test_turn_off_encrypted_websocket_key_type( hass: HomeAssistant, - remoteencws: Mock, + remote_encrypted_websocket: Mock, caplog: pytest.LogCaptureFixture, model: str, expected_key_type: str, ) -> None: """Test for turn_off.""" - entry_data = deepcopy(MOCK_ENTRYDATA_ENCRYPTED_WS) + entry_data = deepcopy(ENTRYDATA_ENCRYPTED_WEBSOCKET) entry_data[CONF_MODEL] = model await setup_samsungtv_entry(hass, entry_data) - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() caplog.clear() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remoteencws.send_commands.call_count == 1 - commands = remoteencws.send_commands.call_args_list[0].args[0] + assert remote_encrypted_websocket.send_commands.call_count == 1 + commands = remote_encrypted_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(command := commands[0], SamsungTVEncryptedCommand) assert command.body["param3"] == expected_key_type assert "Unknown power_off command for" not in caplog.text -async def test_turn_off_legacy(hass: HomeAssistant, remote: Mock) -> None: +async def test_turn_off_legacy(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for turn_off.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_POWEROFF")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_POWEROFF")] async def test_turn_off_os_error( - hass: HomeAssistant, remote: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, remote_legacy: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.close = Mock(side_effect=OSError("BOOM")) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.close = Mock(side_effect=OSError("BOOM")) await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -844,12 +900,12 @@ async def test_turn_off_os_error( @pytest.mark.usefixtures("rest_api") async def test_turn_off_ws_os_error( - hass: HomeAssistant, remotews: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, remote_websocket: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.close = Mock(side_effect=OSError("BOOM")) + remote_websocket.close = Mock(side_effect=OSError("BOOM")) await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -858,43 +914,45 @@ async def test_turn_off_ws_os_error( @pytest.mark.usefixtures("rest_api") async def test_turn_off_encryptedws_os_error( - hass: HomeAssistant, remoteencws: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + remote_encrypted_websocket: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - remoteencws.close = Mock(side_effect=OSError("BOOM")) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) + remote_encrypted_websocket.close = Mock(side_effect=OSError("BOOM")) await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert "Error closing connection" in caplog.text -async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: +async def test_volume_up(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for volume_up.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_VOLUP")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_VOLUP")] -async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: +async def test_volume_down(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for volume_down.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_VOLDOWN")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_VOLDOWN")] -async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: +async def test_mute_volume(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for mute_volume.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_MUTE, @@ -902,74 +960,74 @@ async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: True, ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_MUTE")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_MUTE")] -async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: +async def test_media_play(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for media_play.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_PLAY")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_PLAY")] await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 2 - assert remote.control.call_args_list == [call("KEY_PLAY"), call("KEY_PAUSE")] + assert remote_legacy.control.call_count == 2 + assert remote_legacy.control.call_args_list == [call("KEY_PLAY"), call("KEY_PAUSE")] -async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: +async def test_media_pause(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for media_pause.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_PAUSE")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_PAUSE")] await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 2 - assert remote.control.call_args_list == [call("KEY_PAUSE"), call("KEY_PLAY")] + assert remote_legacy.control.call_count == 2 + assert remote_legacy.control.call_args_list == [call("KEY_PAUSE"), call("KEY_PLAY")] -async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: +async def test_media_next_track(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for media_next_track.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_CHUP")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_CHUP")] -async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: +async def test_media_previous_track(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for media_previous_track.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_CHDOWN")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_CHDOWN")] -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_turn_on_wol(hass: HomeAssistant) -> None: """Test turn on.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_ENTRY_WS_WITH_MAC, + data=ENTRYDATA_WEBSOCKET, unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) @@ -985,21 +1043,21 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: assert mock_send_magic_packet.called -async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: +async def test_turn_on_without_turnon(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test turn on.""" await async_setup_component(hass, "homeassistant", {}) - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with pytest.raises(ServiceNotSupported, match="does not support action"): await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # nothing called as not supported feature - assert remote.control.call_count == 0 + assert remote_legacy.control.call_count == 0 -async def test_play_media(hass: HomeAssistant, remote: Mock) -> None: +async def test_play_media(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for play_media.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch("homeassistant.components.samsungtv.bridge.asyncio.sleep") as sleep: await hass.services.async_call( MP_DOMAIN, @@ -1012,8 +1070,8 @@ async def test_play_media(hass: HomeAssistant, remote: Mock) -> None: True, ) # keys and update called - assert remote.control.call_count == 4 - assert remote.control.call_args_list == [ + assert remote_legacy.control.call_count == 4 + assert remote_legacy.control.call_args_list == [ call("KEY_5"), call("KEY_7"), call("KEY_6"), @@ -1026,7 +1084,7 @@ async def test_play_media_invalid_type(hass: HomeAssistant) -> None: """Test for play_media with invalid media type.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) remote.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1046,7 +1104,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as string.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) remote.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1065,7 +1123,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as non positive integer.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) remote.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1081,9 +1139,9 @@ async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: assert remote.control.call_count == 0 -async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: +async def test_select_source(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for select_source.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_SELECT_SOURCE, @@ -1091,30 +1149,40 @@ async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: True, ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_HDMI")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_HDMI")] async def test_select_source_invalid_source(hass: HomeAssistant) -> None: """Test for select_source with invalid source.""" + + source = "INVALID" + with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) remote.reset_mock() - await hass.services.async_call( - MP_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "INVALID"}, - True, - ) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: source}, + True, + ) # control not called assert remote.control.call_count == 0 + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "source_unsupported" + assert exc_info.value.translation_placeholders == { + "entity": ENTITY_ID, + "source": source, + } @pytest.mark.usefixtures("rest_api") -async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: +async def test_play_media_app(hass: HomeAssistant, remote_websocket: Mock) -> None: """Test for play_media.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1126,19 +1194,21 @@ async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: }, True, ) - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(commands[0], ChannelEmitCommand) assert commands[0].params["data"]["appId"] == "3201608010191" @pytest.mark.usefixtures("rest_api") -async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: +async def test_select_source_app(hass: HomeAssistant, remote_websocket: Mock) -> None: """Test for select_source.""" - remotews.app_list_data = SAMPLE_EVENT_ED_INSTALLED_APP + remote_websocket.app_list_data = load_json_object_fixture( + "ws_installed_app_event.json", DOMAIN + ) await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1146,8 +1216,8 @@ async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "Deezer"}, True, ) - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(commands[0], ChannelEmitCommand) assert commands[0].params["data"]["appId"] == "3201608010191" @@ -1156,7 +1226,7 @@ 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, + remote_websocket: Mock, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, ) -> None: @@ -1166,12 +1236,12 @@ async def test_websocket_unsupported_remote_control( assert entry.data[CONF_METHOD] == METHOD_WEBSOCKET assert entry.data[CONF_PORT] == 8001 - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - remotews.raise_mock_ws_event_callback( + remote_websocket.raise_mock_ws_event_callback( "ms.error", { "event": "ms.error", @@ -1180,8 +1250,8 @@ async def test_websocket_unsupported_remote_control( ) # key called - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(commands[0], SendRemoteKey) assert commands[0].params["DataOfCmd"] == "KEY_POWER" @@ -1210,10 +1280,8 @@ async def test_websocket_unsupported_remote_control( assert state.state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("remotews", "rest_api", "upnp_notify_server") -async def test_volume_control_upnp( - hass: HomeAssistant, dmr_device: Mock, caplog: pytest.LogCaptureFixture -) -> None: +@pytest.mark.usefixtures("remote_websocket", "rest_api", "upnp_notify_server") +async def test_volume_control_upnp(hass: HomeAssistant, dmr_device: Mock) -> None: """Test for Upnp volume control.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1229,24 +1297,24 @@ async def test_volume_control_upnp( True, ) dmr_device.async_set_volume_level.assert_called_once_with(0.5) - assert "Unable to set volume level on" not in caplog.text # Upnp action failed dmr_device.async_set_volume_level.reset_mock() dmr_device.async_set_volume_level.side_effect = UpnpActionResponseError( status=500, error_code=501, error_desc="Action Failed" ) - await hass.services.async_call( - MP_DOMAIN, - SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, - True, - ) + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, + True, + ) + assert err.value.translation_key == "error_set_volume" dmr_device.async_set_volume_level.assert_called_once_with(0.6) - assert "Unable to set volume level on" in caplog.text -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_upnp_not_available( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1264,7 +1332,7 @@ async def test_upnp_not_available( assert "Upnp services are not available" in caplog.text -@pytest.mark.usefixtures("remotews", "rest_api", "upnp_factory") +@pytest.mark.usefixtures("remote_websocket", "rest_api", "upnp_factory") async def test_upnp_missing_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1282,7 +1350,7 @@ async def test_upnp_missing_service( assert "Upnp services are not available" in caplog.text -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_upnp_shutdown( hass: HomeAssistant, dmr_device: Mock, @@ -1303,7 +1371,7 @@ async def test_upnp_shutdown( upnp_notify_server.async_stop_server.assert_called_once() -@pytest.mark.usefixtures("remotews", "rest_api", "upnp_notify_server") +@pytest.mark.usefixtures("remote_websocket", "rest_api", "upnp_notify_server") async def test_upnp_subscribe_events(hass: HomeAssistant, dmr_device: Mock) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1323,7 +1391,7 @@ async def test_upnp_subscribe_events(hass: HomeAssistant, dmr_device: Mock) -> N assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is True -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_upnp_subscribe_events_upnperror( hass: HomeAssistant, dmr_device: Mock, @@ -1338,7 +1406,7 @@ async def test_upnp_subscribe_events_upnperror( assert "Error while subscribing during device connect" in caplog.text -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_upnp_subscribe_events_upnpresponseerror( hass: HomeAssistant, dmr_device: Mock, @@ -1361,7 +1429,7 @@ async def test_upnp_subscribe_events_upnpresponseerror( async def test_upnp_re_subscribe_events( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - remotews: Mock, + remote_websocket: Mock, dmr_device: Mock, ) -> None: """Test for Upnp event feedback.""" @@ -1374,9 +1442,9 @@ async def test_upnp_re_subscribe_events( with ( patch.object( - remotews, "start_listening", side_effect=WebSocketException("Boom") + remote_websocket, "start_listening", side_effect=WebSocketException("Boom") ), - patch.object(remotews, "is_alive", return_value=False), + patch.object(remote_websocket, "is_alive", return_value=False), ): freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -1405,7 +1473,7 @@ async def test_upnp_re_subscribe_events( async def test_upnp_failed_re_subscribe_events( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - remotews: Mock, + remote_websocket: Mock, dmr_device: Mock, caplog: pytest.LogCaptureFixture, error: Exception, @@ -1420,9 +1488,9 @@ async def test_upnp_failed_re_subscribe_events( with ( patch.object( - remotews, "start_listening", side_effect=WebSocketException("Boom") + remote_websocket, "start_listening", side_effect=WebSocketException("Boom") ), - patch.object(remotews, "is_alive", return_value=False), + patch.object(remote_websocket, "is_alive", return_value=False), ): freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py index 65474979968..ec161773c1e 100644 --- a/tests/components/samsungtv/test_remote.py +++ b/tests/components/samsungtv/test_remote.py @@ -17,39 +17,41 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_samsungtv_entry -from .const import MOCK_CONFIG, MOCK_ENTRY_WS_WITH_MAC, MOCK_ENTRYDATA_ENCRYPTED_WS +from .const import ENTRYDATA_ENCRYPTED_WEBSOCKET, ENTRYDATA_LEGACY, ENTRYDATA_WEBSOCKET from tests.common import MockConfigEntry -ENTITY_ID = f"{REMOTE_DOMAIN}.fake" +ENTITY_ID = f"{REMOTE_DOMAIN}.mock_title" -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_setup(hass: HomeAssistant) -> None: """Test setup with basic config.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) assert hass.states.get(ENTITY_ID) -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test unique id.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) main = entity_registry.async_get(ENTITY_ID) assert main.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_main_services( - hass: HomeAssistant, remoteencws: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + remote_encrypted_websocket: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for turn_off.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() await hass.services.async_call( REMOTE_DOMAIN, @@ -59,8 +61,8 @@ async def test_main_services( ) # key called - assert remoteencws.send_commands.call_count == 1 - commands = remoteencws.send_commands.call_args_list[0].args[0] + assert remote_encrypted_websocket.send_commands.call_count == 1 + commands = remote_encrypted_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 2 assert isinstance(command := commands[0], SamsungTVEncryptedCommand) assert command.body["param3"] == "KEY_POWEROFF" @@ -68,7 +70,7 @@ async def test_main_services( assert command.body["param3"] == "KEY_POWER" # commands not sent : power off in progress - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() await hass.services.async_call( REMOTE_DOMAIN, SERVICE_SEND_COMMAND, @@ -76,13 +78,15 @@ async def test_main_services( blocking=True, ) assert "TV is powering off, not sending keys: ['dash']" in caplog.text - remoteencws.send_commands.assert_not_called() + remote_encrypted_websocket.send_commands.assert_not_called() -@pytest.mark.usefixtures("remoteencws", "rest_api") -async def test_send_command_service(hass: HomeAssistant, remoteencws: Mock) -> None: +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") +async def test_send_command_service( + hass: HomeAssistant, remote_encrypted_websocket: Mock +) -> None: """Test the send command.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) await hass.services.async_call( REMOTE_DOMAIN, @@ -91,19 +95,19 @@ async def test_send_command_service(hass: HomeAssistant, remoteencws: Mock) -> N blocking=True, ) - assert remoteencws.send_commands.call_count == 1 - commands = remoteencws.send_commands.call_args_list[0].args[0] + assert remote_encrypted_websocket.send_commands.call_count == 1 + commands = remote_encrypted_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(command := commands[0], SamsungTVEncryptedCommand) assert command.body["param3"] == "dash" -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_turn_on_wol(hass: HomeAssistant) -> None: """Test turn on.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_ENTRY_WS_WITH_MAC, + data=ENTRYDATA_WEBSOCKET, unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) @@ -119,15 +123,15 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: assert mock_send_magic_packet.called -async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: +async def test_turn_on_without_turnon(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test turn on.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # nothing called as not supported feature - assert remote.control.call_count == 0 + assert remote_legacy.control.call_count == 0 assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "service_unsupported" assert exc_info.value.translation_placeholders == { diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index d957e501775..e2155bca834 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -12,12 +12,12 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry -from .const import MOCK_ENTRYDATA_ENCRYPTED_WS +from .const import ENTRYDATA_ENCRYPTED_WEBSOCKET from tests.common import MockEntity, MockEntityPlatform -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") @pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_turn_on_trigger_device_id( hass: HomeAssistant, @@ -26,9 +26,9 @@ async def test_turn_on_trigger_device_id( entity_domain: str, ) -> None: """Test for turn_on triggers by device_id firing.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - entity_id = f"{entity_domain}.fake" + entity_id = f"{entity_domain}.mock_title" device = device_registry.async_get_device( identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} @@ -84,15 +84,15 @@ async def test_turn_on_trigger_device_id( mock_send_magic_packet.assert_called() -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") @pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_turn_on_trigger_entity_id( hass: HomeAssistant, service_calls: list[ServiceCall], entity_domain: str ) -> None: """Test for turn_on triggers by entity_id firing.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - entity_id = f"{entity_domain}.fake" + entity_id = f"{entity_domain}.mock_title" assert await async_setup_component( hass, @@ -126,13 +126,13 @@ async def test_turn_on_trigger_entity_id( assert service_calls[1].data["id"] == 0 -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") @pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_wrong_trigger_platform_type( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, entity_domain: str ) -> None: """Test wrong trigger platform type.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) entity_id = f"{entity_domain}.fake" await async_setup_component( @@ -163,13 +163,13 @@ async def test_wrong_trigger_platform_type( ) -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") @pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_trigger_invalid_entity_id( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, entity_domain: str ) -> None: """Test turn on trigger using invalid entity_id.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) entity_id = f"{entity_domain}.fake" platform = MockEntityPlatform(hass) diff --git a/tests/components/sanix/snapshots/test_sensor.ambr b/tests/components/sanix/snapshots/test_sensor.ambr index 6cf0254b66b..eadd2db17b4 100644 --- a/tests/components/sanix/snapshots/test_sensor.ambr +++ b/tests/components/sanix/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'sanix', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1810088-battery', @@ -79,6 +80,7 @@ 'original_name': 'Device number', 'platform': 'sanix', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_no', 'unique_id': '1810088-device_no', @@ -122,12 +124,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Distance', 'platform': 'sanix', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1810088-distance', @@ -180,6 +186,7 @@ 'original_name': 'Filled', 'platform': 'sanix', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fill_perc', 'unique_id': '1810088-fill_perc', @@ -229,6 +236,7 @@ 'original_name': 'Service date', 'platform': 'sanix', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'service_date', 'unique_id': '1810088-service_date', @@ -277,6 +285,7 @@ 'original_name': 'SSID', 'platform': 'sanix', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ssid', 'unique_id': '1810088-ssid', diff --git a/tests/components/sanix/test_sensor.py b/tests/components/sanix/test_sensor.py index d9729ca3c25..f7fbfa61f3f 100644 --- a/tests/components/sanix/test_sensor.py +++ b/tests/components/sanix/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/sense/snapshots/test_binary_sensor.ambr b/tests/components/sense/snapshots/test_binary_sensor.ambr index 7221a0bc518..aa803b40bd1 100644 --- a/tests/components/sense/snapshots/test_binary_sensor.ambr +++ b/tests/components/sense/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Power', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-abc123', @@ -77,6 +78,7 @@ 'original_name': 'Power', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-def456', diff --git a/tests/components/sense/snapshots/test_sensor.ambr b/tests/components/sense/snapshots/test_sensor.ambr index 0a68553cf04..d1b0c90aa23 100644 --- a/tests/components/sense/snapshots/test_sensor.ambr +++ b/tests/components/sense/snapshots/test_sensor.ambr @@ -32,6 +32,7 @@ 'original_name': 'Bill energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bill_energy', 'unique_id': '12345-abc123-bill-energy', @@ -89,6 +90,7 @@ 'original_name': 'Daily energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_energy', 'unique_id': '12345-abc123-daily-energy', @@ -146,6 +148,7 @@ 'original_name': 'Monthly energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monthly_energy', 'unique_id': '12345-abc123-monthly-energy', @@ -194,12 +197,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': 'mdi:car-electric', 'original_name': 'Power', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-abc123-usage', @@ -257,6 +264,7 @@ 'original_name': 'Weekly energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'weekly_energy', 'unique_id': '12345-abc123-weekly-energy', @@ -314,6 +322,7 @@ 'original_name': 'Yearly energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yearly_energy', 'unique_id': '12345-abc123-yearly-energy', @@ -371,6 +380,7 @@ 'original_name': 'Bill energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bill_energy', 'unique_id': '12345-def456-bill-energy', @@ -428,6 +438,7 @@ 'original_name': 'Daily energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_energy', 'unique_id': '12345-def456-daily-energy', @@ -485,6 +496,7 @@ 'original_name': 'Monthly energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monthly_energy', 'unique_id': '12345-def456-monthly-energy', @@ -533,12 +545,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': 'mdi:stove', 'original_name': 'Power', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-def456-usage', @@ -596,6 +612,7 @@ 'original_name': 'Weekly energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'weekly_energy', 'unique_id': '12345-def456-weekly-energy', @@ -653,6 +670,7 @@ 'original_name': 'Yearly energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yearly_energy', 'unique_id': '12345-def456-yearly-energy', @@ -701,12 +719,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Bill Energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-bill-usage', @@ -755,12 +777,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Bill From Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-bill-from_grid', @@ -809,12 +835,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Bill Net Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-bill-net_production', @@ -867,6 +897,7 @@ 'original_name': 'Bill Net Production Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-bill-production_pct', @@ -912,12 +943,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Bill Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-bill-production', @@ -970,6 +1005,7 @@ 'original_name': 'Bill Solar Powered Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-bill-solar_powered', @@ -1015,12 +1051,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Bill To Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-bill-to_grid', @@ -1069,12 +1109,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Daily Energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-daily-usage', @@ -1123,12 +1167,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Daily From Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-daily-from_grid', @@ -1177,12 +1225,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Daily Net Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-daily-net_production', @@ -1235,6 +1287,7 @@ 'original_name': 'Daily Net Production Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-daily-production_pct', @@ -1280,12 +1333,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Daily Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-daily-production', @@ -1338,6 +1395,7 @@ 'original_name': 'Daily Solar Powered Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-daily-solar_powered', @@ -1383,12 +1441,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Daily To Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-daily-to_grid', @@ -1437,12 +1499,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-active-usage', @@ -1490,12 +1556,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'L1 Voltage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-L1', @@ -1543,12 +1613,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'L2 Voltage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-L2', @@ -1596,12 +1670,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Monthly Energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-monthly-usage', @@ -1650,12 +1728,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Monthly From Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-monthly-from_grid', @@ -1704,12 +1786,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Monthly Net Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-monthly-net_production', @@ -1762,6 +1848,7 @@ 'original_name': 'Monthly Net Production Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-monthly-production_pct', @@ -1807,12 +1894,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Monthly Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-monthly-production', @@ -1865,6 +1956,7 @@ 'original_name': 'Monthly Solar Powered Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-monthly-solar_powered', @@ -1910,12 +2002,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Monthly To Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-monthly-to_grid', @@ -1964,12 +2060,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-active-production', @@ -2017,12 +2117,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Weekly Energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-weekly-usage', @@ -2071,12 +2175,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Weekly From Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-weekly-from_grid', @@ -2125,12 +2233,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Weekly Net Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-weekly-net_production', @@ -2183,6 +2295,7 @@ 'original_name': 'Weekly Net Production Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-weekly-production_pct', @@ -2228,12 +2341,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Weekly Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-weekly-production', @@ -2286,6 +2403,7 @@ 'original_name': 'Weekly Solar Powered Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-weekly-solar_powered', @@ -2331,12 +2449,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Weekly To Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-weekly-to_grid', @@ -2385,12 +2507,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Yearly Energy', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-yearly-usage', @@ -2439,12 +2565,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Yearly From Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-yearly-from_grid', @@ -2493,12 +2623,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Yearly Net Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-yearly-net_production', @@ -2551,6 +2685,7 @@ 'original_name': 'Yearly Net Production Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-yearly-production_pct', @@ -2596,12 +2731,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Yearly Production', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-yearly-production', @@ -2654,6 +2793,7 @@ 'original_name': 'Yearly Solar Powered Percentage', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-yearly-solar_powered', @@ -2699,12 +2839,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Yearly To Grid', 'platform': 'sense', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-yearly-to_grid', diff --git a/tests/components/sensibo/snapshots/test_binary_sensor.ambr b/tests/components/sensibo/snapshots/test_binary_sensor.ambr index 2e62c73acb4..fb12dce55ac 100644 --- a/tests/components/sensibo/snapshots/test_binary_sensor.ambr +++ b/tests/components/sensibo/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Filter clean required', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_clean', 'unique_id': 'BBZZBBZZ-filter_clean', @@ -75,6 +76,7 @@ 'original_name': 'Pure Boost linked with AC', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_ac_integration', 'unique_id': 'BBZZBBZZ-pure_ac_integration', @@ -123,6 +125,7 @@ 'original_name': 'Pure Boost linked with indoor air quality', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_measure_integration', 'unique_id': 'BBZZBBZZ-pure_measure_integration', @@ -171,6 +174,7 @@ 'original_name': 'Pure Boost linked with outdoor air quality', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_prime_integration', 'unique_id': 'BBZZBBZZ-pure_prime_integration', @@ -219,6 +223,7 @@ 'original_name': 'Pure Boost linked with presence', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_geo_integration', 'unique_id': 'BBZZBBZZ-pure_geo_integration', @@ -267,6 +272,7 @@ 'original_name': 'Filter clean required', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_clean', 'unique_id': 'ABC999111-filter_clean', @@ -315,6 +321,7 @@ 'original_name': 'Connectivity', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AABBCC-alive', @@ -363,6 +370,7 @@ 'original_name': 'Main sensor', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_main_sensor', 'unique_id': 'AABBCC-is_main_sensor', @@ -410,6 +418,7 @@ 'original_name': 'Motion', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AABBCC-motion', @@ -458,6 +467,7 @@ 'original_name': 'Room occupied', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'room_occupied', 'unique_id': 'ABC999111-room_occupied', @@ -506,6 +516,7 @@ 'original_name': 'Filter clean required', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_clean', 'unique_id': 'AAZZAAZZ-filter_clean', @@ -554,6 +565,7 @@ 'original_name': 'Pure Boost linked with AC', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_ac_integration', 'unique_id': 'AAZZAAZZ-pure_ac_integration', @@ -602,6 +614,7 @@ 'original_name': 'Pure Boost linked with indoor air quality', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_measure_integration', 'unique_id': 'AAZZAAZZ-pure_measure_integration', @@ -650,6 +663,7 @@ 'original_name': 'Pure Boost linked with outdoor air quality', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_prime_integration', 'unique_id': 'AAZZAAZZ-pure_prime_integration', @@ -698,6 +712,7 @@ 'original_name': 'Pure Boost linked with presence', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_geo_integration', 'unique_id': 'AAZZAAZZ-pure_geo_integration', diff --git a/tests/components/sensibo/snapshots/test_button.ambr b/tests/components/sensibo/snapshots/test_button.ambr index 6bfc4a5a44f..3632560b861 100644 --- a/tests/components/sensibo/snapshots/test_button.ambr +++ b/tests/components/sensibo/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Reset filter', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_filter', 'unique_id': 'BBZZBBZZ-reset_filter', @@ -74,6 +75,7 @@ 'original_name': 'Reset filter', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_filter', 'unique_id': 'ABC999111-reset_filter', @@ -121,6 +123,7 @@ 'original_name': 'Reset filter', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_filter', 'unique_id': 'AAZZAAZZ-reset_filter', diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr index e3bd456ad23..fc6e6f64be8 100644 --- a/tests/components/sensibo/snapshots/test_climate.ambr +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -34,6 +34,7 @@ 'original_name': None, 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'climate_device', 'unique_id': 'BBZZBBZZ', @@ -116,6 +117,7 @@ 'original_name': None, 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'climate_device', 'unique_id': 'ABC999111', @@ -208,6 +210,7 @@ 'original_name': None, 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'climate_device', 'unique_id': 'AAZZAAZZ', diff --git a/tests/components/sensibo/snapshots/test_number.ambr b/tests/components/sensibo/snapshots/test_number.ambr index 458c7ca7183..e1556b3cdf8 100644 --- a/tests/components/sensibo/snapshots/test_number.ambr +++ b/tests/components/sensibo/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Humidity calibration', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calibration_humidity', 'unique_id': 'BBZZBBZZ-calibration_hum', @@ -90,6 +91,7 @@ 'original_name': 'Temperature calibration', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calibration_temperature', 'unique_id': 'BBZZBBZZ-calibration_temp', @@ -148,6 +150,7 @@ 'original_name': 'Humidity calibration', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calibration_humidity', 'unique_id': 'ABC999111-calibration_hum', @@ -206,6 +209,7 @@ 'original_name': 'Temperature calibration', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calibration_temperature', 'unique_id': 'ABC999111-calibration_temp', @@ -264,6 +268,7 @@ 'original_name': 'Humidity calibration', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calibration_humidity', 'unique_id': 'AAZZAAZZ-calibration_hum', @@ -322,6 +327,7 @@ 'original_name': 'Temperature calibration', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calibration_temperature', 'unique_id': 'AAZZAAZZ-calibration_temp', diff --git a/tests/components/sensibo/snapshots/test_select.ambr b/tests/components/sensibo/snapshots/test_select.ambr index 05582a1ea16..2ac6eb445a5 100644 --- a/tests/components/sensibo/snapshots/test_select.ambr +++ b/tests/components/sensibo/snapshots/test_select.ambr @@ -32,6 +32,7 @@ 'original_name': 'Light', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'ABC999111-light', @@ -89,6 +90,7 @@ 'original_name': 'Light', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light', 'unique_id': 'AAZZAAZZ-light', diff --git a/tests/components/sensibo/snapshots/test_sensor.ambr b/tests/components/sensibo/snapshots/test_sensor.ambr index bfd5f2d3e9a..98552394ccc 100644 --- a/tests/components/sensibo/snapshots/test_sensor.ambr +++ b/tests/components/sensibo/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Filter last reset', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_last_reset', 'unique_id': 'BBZZBBZZ-filter_last_reset', @@ -81,6 +82,7 @@ 'original_name': 'Pure AQI', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pm25_pure', 'unique_id': 'BBZZBBZZ-pm25', @@ -134,6 +136,7 @@ 'original_name': 'Pure sensitivity', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensitivity', 'unique_id': 'BBZZBBZZ-pure_sensitivity', @@ -177,12 +180,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Climate React high temperature threshold', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_react_high', 'unique_id': 'ABC999111-climate_react_high', @@ -237,12 +244,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Climate React low temperature threshold', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_react_low', 'unique_id': 'ABC999111-climate_react_low', @@ -301,6 +312,7 @@ 'original_name': 'Climate React type', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smart_type', 'unique_id': 'ABC999111-climate_react_type', @@ -348,6 +360,7 @@ 'original_name': 'Filter last reset', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_last_reset', 'unique_id': 'ABC999111-filter_last_reset', @@ -392,12 +405,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery voltage', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_voltage', 'unique_id': 'AABBCC-battery_voltage', @@ -450,6 +467,7 @@ 'original_name': 'Humidity', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AABBCC-humidity', @@ -502,6 +520,7 @@ 'original_name': 'RSSI', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rssi', 'unique_id': 'AABBCC-rssi', @@ -548,12 +567,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AABBCC-temperature', @@ -600,12 +623,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature feels like', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'feels_like', 'unique_id': 'ABC999111-feels_like', @@ -656,6 +683,7 @@ 'original_name': 'Timer end time', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'timer_time', 'unique_id': 'ABC999111-timer_time', @@ -706,6 +734,7 @@ 'original_name': 'Filter last reset', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_last_reset', 'unique_id': 'AAZZAAZZ-filter_last_reset', @@ -760,6 +789,7 @@ 'original_name': 'Pure AQI', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pm25_pure', 'unique_id': 'AAZZAAZZ-pm25', @@ -813,6 +843,7 @@ 'original_name': 'Pure sensitivity', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensitivity', 'unique_id': 'AAZZAAZZ-pure_sensitivity', diff --git a/tests/components/sensibo/snapshots/test_switch.ambr b/tests/components/sensibo/snapshots/test_switch.ambr index e0ea140eb37..f52f650ee7d 100644 --- a/tests/components/sensibo/snapshots/test_switch.ambr +++ b/tests/components/sensibo/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Pure Boost', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_boost_switch', 'unique_id': 'BBZZBBZZ-pure_boost_switch', @@ -75,6 +76,7 @@ 'original_name': 'Climate React', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_react_switch', 'unique_id': 'ABC999111-climate_react_switch', @@ -124,6 +126,7 @@ 'original_name': 'Timer', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'timer_on_switch', 'unique_id': 'ABC999111-timer_on_switch', @@ -174,6 +177,7 @@ 'original_name': 'Pure Boost', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pure_boost_switch', 'unique_id': 'AAZZAAZZ-pure_boost_switch', diff --git a/tests/components/sensibo/snapshots/test_update.ambr b/tests/components/sensibo/snapshots/test_update.ambr index c113d5615b1..b5e4b159264 100644 --- a/tests/components/sensibo/snapshots/test_update.ambr +++ b/tests/components/sensibo/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': 'Firmware', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'BBZZBBZZ-fw_ver_available', @@ -87,6 +88,7 @@ 'original_name': 'Firmware', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ABC999111-fw_ver_available', @@ -147,6 +149,7 @@ 'original_name': 'Firmware', 'platform': 'sensibo', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AAZZAAZZ-fw_ver_available', diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 458009b2690..4fb9a1e4f7f 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -14,6 +14,7 @@ from homeassistant.const import ( UnitOfApparentPower, UnitOfFrequency, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfReactivePower, UnitOfVolume, ) @@ -44,6 +45,7 @@ UNITS_OF_MEASUREMENT = { SensorDeviceClass.ENERGY: "kWh", # energy (Wh/kWh/MWh) SensorDeviceClass.FREQUENCY: UnitOfFrequency.GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) SensorDeviceClass.POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) + SensorDeviceClass.REACTIVE_ENERGY: UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, # reactive energy (varh) SensorDeviceClass.REACTIVE_POWER: UnitOfReactivePower.VOLT_AMPERE_REACTIVE, # reactive power (var) SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs SensorDeviceClass.VOLTAGE: "V", # voltage (V) diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index a9781e0b800..68488d29c67 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -119,7 +119,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert len(conditions) == 27 + assert len(conditions) == 28 assert conditions == unordered(expected_conditions) diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index f35c9520f71..bf7147e30e1 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -121,7 +121,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 27 + assert len(triggers) == 28 assert triggers == unordered(expected_triggers) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 9666e29579b..f1d527a2b9b 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -24,19 +24,32 @@ from homeassistant.components.sensor import ( async_rounded_state, async_update_suggested_units, ) -from homeassistant.components.sensor.const import STATE_CLASS_UNITS +from homeassistant.components.sensor.const import STATE_CLASS_UNITS, UNIT_CONVERTERS from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNKNOWN, EntityCategory, + UnitOfApparentPower, UnitOfArea, + UnitOfBloodGlucoseConcentration, + UnitOfConductivity, UnitOfDataRate, + UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, + UnitOfFrequency, + UnitOfInformation, + UnitOfIrradiance, UnitOfLength, UnitOfMass, + UnitOfPower, + UnitOfPrecipitationDepth, UnitOfPressure, + UnitOfReactivePower, + UnitOfSoundPressure, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -78,28 +91,28 @@ TEST_DOMAIN = "test" UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.FAHRENHEIT, 100, - "100", + 100, ), ( US_CUSTOMARY_SYSTEM, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT, 38, - "100", + 100.4, ), ( METRIC_SYSTEM, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS, 100, - "38", + pytest.approx(37.77778), ), ( METRIC_SYSTEM, UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS, 38, - "38", + 38, ), ], ) @@ -125,7 +138,7 @@ async def test_temperature_conversion( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == state_value + assert float(state.state) == state_value assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit @@ -593,6 +606,8 @@ async def test_unit_translation_key_without_platform_raises( "state_unit", "native_value", "custom_state", + "rounded_state", + "suggested_precision", ), [ # Smaller to larger unit, InHg is ~33x larger than hPa -> 1 more decimal @@ -602,7 +617,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfPressure.INHG, UnitOfPressure.INHG, 1000.0, + pytest.approx(29.52998), "29.53", + 2, ), ( SensorDeviceClass.PRESSURE, @@ -610,7 +627,19 @@ async def test_unit_translation_key_without_platform_raises( UnitOfPressure.HPA, UnitOfPressure.HPA, 1.234, - "12.340", + 12.34, + "12.34", + 2, + ), + ( + SensorDeviceClass.PRESSURE, + UnitOfPressure.HPA, + UnitOfPressure.PA, + UnitOfPressure.PA, + 1.234, + 123.4, + "123", + 0, ), ( SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -618,7 +647,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfPressure.MMHG, UnitOfPressure.MMHG, 1000, - "750", + pytest.approx(750.061575), + "750.06", + 2, ), ( SensorDeviceClass.PRESSURE, @@ -626,7 +657,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfPressure.MMHG, UnitOfPressure.MMHG, 1000, - "750", + pytest.approx(750.061575), + "750.06", + 2, ), # Not a supported pressure unit ( @@ -635,7 +668,9 @@ async def test_unit_translation_key_without_platform_raises( "peer_pressure", UnitOfPressure.HPA, 1000, - "1000", + 1000, + "1000.00", + 2, ), ( SensorDeviceClass.TEMPERATURE, @@ -643,7 +678,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.FAHRENHEIT, 37.5, + 99.5, "99.5", + 1, ), ( SensorDeviceClass.TEMPERATURE, @@ -651,7 +688,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS, 100, - "38", + pytest.approx(37.77777), + "37.8", + 1, ), ( SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -659,7 +698,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfPressure.HPA, UnitOfPressure.HPA, -0.00, - "0.0", + 0.0, + "0.00", + 2, ), ( SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -667,7 +708,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfPressure.HPA, UnitOfPressure.HPA, -0.00001, - "0", + pytest.approx(-0.0003386388), + "0.00", + 2, ), ( SensorDeviceClass.VOLUME_FLOW_RATE, @@ -675,7 +718,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, 50.0, - "13.2", + pytest.approx(13.208602), + "13", + 0, ), ( SensorDeviceClass.VOLUME_FLOW_RATE, @@ -683,7 +728,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfVolumeFlowRate.LITERS_PER_MINUTE, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, 13.0, - "49.2", + pytest.approx(49.2103531), + "49", + 0, ), ( SensorDeviceClass.DURATION, @@ -691,7 +738,9 @@ async def test_unit_translation_key_without_platform_raises( UnitOfTime.HOURS, UnitOfTime.HOURS, 5400.0, - "1.5000", + 1.5, + "1.50", + 2, ), ( SensorDeviceClass.DURATION, @@ -699,7 +748,29 @@ async def test_unit_translation_key_without_platform_raises( UnitOfTime.MINUTES, UnitOfTime.MINUTES, 0.5, - "720.0", + 720, + "720.00", + 2, + ), + ( + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION, + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, + 130, + pytest.approx(7.222222), + "7.2", + 1, + ), + ( + SensorDeviceClass.ENERGY, + UnitOfEnergy.WATT_HOUR, + UnitOfEnergy.KILO_WATT_HOUR, + UnitOfEnergy.KILO_WATT_HOUR, + 1.1, + 0.0011, + "0.00", + 2, ), ], ) @@ -712,6 +783,8 @@ async def test_custom_unit( state_unit, native_value, custom_state, + rounded_state, + suggested_precision, ) -> None: """Test custom unit.""" entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") @@ -734,13 +807,17 @@ async def test_custom_unit( entity_id = entity0.entity_id state = hass.states.get(entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit assert ( - async_rounded_state(hass, entity_id, hass.states.get(entity_id)) == custom_state + async_rounded_state(hass, entity_id, hass.states.get(entity_id)) + == rounded_state ) + entry = entity_registry.async_get(entity0.entity_id) + assert entry.options["sensor"]["suggested_display_precision"] == suggested_precision + @pytest.mark.parametrize( ( @@ -759,8 +836,8 @@ async def test_custom_unit( UnitOfArea.SQUARE_MILES, UnitOfArea.SQUARE_MILES, 1000, - "1000", - "386", + 1000, + pytest.approx(386.102), SensorDeviceClass.AREA, ), ( @@ -768,8 +845,8 @@ async def test_custom_unit( UnitOfArea.SQUARE_INCHES, UnitOfArea.SQUARE_INCHES, 7.24, - "7.24", - "1.12", + 7.24, + pytest.approx(1.1222022), SensorDeviceClass.AREA, ), ( @@ -777,8 +854,8 @@ async def test_custom_unit( "peer_distance", UnitOfArea.SQUARE_KILOMETERS, 1000, - "1000", - "1000", + 1000, + 1000, SensorDeviceClass.AREA, ), # Distance @@ -787,8 +864,8 @@ async def test_custom_unit( UnitOfLength.MILES, UnitOfLength.MILES, 1000, - "1000", - "621", + 1000, + pytest.approx(621.371), SensorDeviceClass.DISTANCE, ), ( @@ -796,8 +873,8 @@ async def test_custom_unit( UnitOfLength.INCHES, UnitOfLength.INCHES, 7.24, - "7.24", - "2.85", + 7.24, + pytest.approx(2.8503937), SensorDeviceClass.DISTANCE, ), ( @@ -805,8 +882,8 @@ async def test_custom_unit( "peer_distance", UnitOfLength.KILOMETERS, 1000, - "1000", - "1000", + 1000, + 1000, SensorDeviceClass.DISTANCE, ), # Energy @@ -815,8 +892,8 @@ async def test_custom_unit( UnitOfEnergy.MEGA_WATT_HOUR, UnitOfEnergy.MEGA_WATT_HOUR, 1000, - "1000", - "1.000", + 1000, + 1.000, SensorDeviceClass.ENERGY, ), ( @@ -824,8 +901,8 @@ async def test_custom_unit( UnitOfEnergy.MEGA_WATT_HOUR, UnitOfEnergy.MEGA_WATT_HOUR, 1000, - "1000", - "278", + 1000, + pytest.approx(277.7778), SensorDeviceClass.ENERGY, ), ( @@ -833,8 +910,8 @@ async def test_custom_unit( "BTU", UnitOfEnergy.KILO_WATT_HOUR, 1000, - "1000", - "1000", + 1000, + 1000, SensorDeviceClass.ENERGY, ), # Power factor @@ -843,8 +920,8 @@ async def test_custom_unit( PERCENTAGE, PERCENTAGE, 1.0, - "1.0", - "100.0", + 1.0, + 100.0, SensorDeviceClass.POWER_FACTOR, ), ( @@ -852,8 +929,8 @@ async def test_custom_unit( None, None, 100, - "100", - "1.00", + 100, + 1.00, SensorDeviceClass.POWER_FACTOR, ), ( @@ -861,8 +938,8 @@ async def test_custom_unit( None, "Cos φ", 1.0, - "1.0", - "1.0", + 1.0, + 1.0, SensorDeviceClass.POWER_FACTOR, ), # Pressure @@ -872,8 +949,8 @@ async def test_custom_unit( UnitOfPressure.INHG, UnitOfPressure.INHG, 1000.0, - "1000.0", - "29.53", + 1000.0, + pytest.approx(29.52998), SensorDeviceClass.PRESSURE, ), ( @@ -881,8 +958,8 @@ async def test_custom_unit( UnitOfPressure.HPA, UnitOfPressure.HPA, 1.234, - "1.234", - "12.340", + 1.234, + 12.340, SensorDeviceClass.PRESSURE, ), ( @@ -890,8 +967,8 @@ async def test_custom_unit( UnitOfPressure.MMHG, UnitOfPressure.MMHG, 1000, - "1000", - "750", + 1000, + pytest.approx(750.0615), SensorDeviceClass.PRESSURE, ), # Not a supported pressure unit @@ -900,8 +977,8 @@ async def test_custom_unit( "peer_pressure", UnitOfPressure.HPA, 1000, - "1000", - "1000", + 1000, + 1000, SensorDeviceClass.PRESSURE, ), # Speed @@ -910,8 +987,8 @@ async def test_custom_unit( UnitOfSpeed.MILES_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR, 100, - "100", - "62", + 100, + pytest.approx(62.1371), SensorDeviceClass.SPEED, ), ( @@ -919,8 +996,8 @@ async def test_custom_unit( UnitOfVolumetricFlux.INCHES_PER_HOUR, UnitOfVolumetricFlux.INCHES_PER_HOUR, 78, - "78", - "0.13", + 78, + pytest.approx(0.127952755), SensorDeviceClass.SPEED, ), ( @@ -928,8 +1005,8 @@ async def test_custom_unit( "peer_distance", UnitOfSpeed.KILOMETERS_PER_HOUR, 100, - "100", - "100", + 100, + 100, SensorDeviceClass.SPEED, ), # Volume @@ -938,8 +1015,8 @@ async def test_custom_unit( UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_FEET, 100, - "100", - "3531", + 100, + pytest.approx(3531.4667), SensorDeviceClass.VOLUME, ), ( @@ -947,8 +1024,8 @@ async def test_custom_unit( UnitOfVolume.FLUID_OUNCES, UnitOfVolume.FLUID_OUNCES, 2.3, - "2.3", - "77.8", + 2.3, + pytest.approx(77.77225), SensorDeviceClass.VOLUME, ), ( @@ -956,8 +1033,8 @@ async def test_custom_unit( "peer_distance", UnitOfVolume.CUBIC_METERS, 100, - "100", - "100", + 100, + 100, SensorDeviceClass.VOLUME, ), # Weight @@ -966,8 +1043,8 @@ async def test_custom_unit( UnitOfMass.OUNCES, UnitOfMass.OUNCES, 100, - "100", - "3.5", + 100, + pytest.approx(3.5273962), SensorDeviceClass.WEIGHT, ), ( @@ -975,8 +1052,8 @@ async def test_custom_unit( UnitOfMass.GRAMS, UnitOfMass.GRAMS, 78, - "78", - "2211", + 78, + pytest.approx(2211.262), SensorDeviceClass.WEIGHT, ), ( @@ -984,8 +1061,8 @@ async def test_custom_unit( "peer_distance", UnitOfMass.GRAMS, 100, - "100", - "100", + 100, + 100, SensorDeviceClass.WEIGHT, ), ], @@ -1015,7 +1092,7 @@ async def test_custom_unit_change( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == native_state + assert float(state.state) == native_state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == native_unit entity_registry.async_update_entity_options( @@ -1024,7 +1101,7 @@ async def test_custom_unit_change( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == state_unit entity_registry.async_update_entity_options( @@ -1033,14 +1110,14 @@ async def test_custom_unit_change( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == native_state + assert float(state.state) == native_state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == native_unit entity_registry.async_update_entity_options("sensor.test", "sensor", None) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == native_state + assert float(state.state) == native_state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == native_unit @@ -1067,10 +1144,10 @@ async def test_custom_unit_change( UnitOfLength.METERS, UnitOfLength.YARDS, 1000, - "1000", - "621", - "1000000", - "1093613", + 1000, + pytest.approx(621.371), + 1000000, + pytest.approx(1093613), SensorDeviceClass.DISTANCE, ), # Volume Storage (subclass of Volume) @@ -1081,10 +1158,10 @@ async def test_custom_unit_change( UnitOfVolume.GALLONS, UnitOfVolume.FLUID_OUNCES, 1000, - "1000", - "264", - "264", - "33814", + 1000, + pytest.approx(264.172), + pytest.approx(264.172), + pytest.approx(33814.022), SensorDeviceClass.VOLUME_STORAGE, ), ], @@ -1152,34 +1229,36 @@ async def test_unit_conversion_priority( # Registered entity -> Follow automatic unit conversion state = hass.states.get(entity0.entity_id) - assert state.state == automatic_state + assert float(state.state) == automatic_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit # Assert the automatic unit conversion is stored in the registry entry = entity_registry.async_get(entity0.entity_id) assert entry.unit_of_measurement == automatic_unit - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": automatic_unit} - } + assert ( + entry.options["sensor.private"]["suggested_unit_of_measurement"] + == automatic_unit + ) # Unregistered entity -> Follow native unit state = hass.states.get(entity1.entity_id) - assert state.state == native_state + assert float(state.state) == native_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit # Registered entity with suggested unit state = hass.states.get(entity2.entity_id) - assert state.state == suggested_state + assert float(state.state) == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity2.entity_id) assert entry.unit_of_measurement == suggested_unit - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": suggested_unit} - } + assert ( + entry.options["sensor.private"]["suggested_unit_of_measurement"] + == suggested_unit + ) # Unregistered entity with suggested unit state = hass.states.get(entity3.entity_id) - assert state.state == suggested_state + assert float(state.state) == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Set a custom unit, this should have priority over the automatic unit conversion @@ -1189,7 +1268,7 @@ async def test_unit_conversion_priority( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit entity_registry.async_update_entity_options( @@ -1198,7 +1277,7 @@ async def test_unit_conversion_priority( await hass.async_block_till_done() state = hass.states.get(entity2.entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit @@ -1387,7 +1466,6 @@ async def test_unit_conversion_priority_precision( {"display_precision": 4}, ) entry4 = entity_registry.async_get(entity4.entity_id) - assert "suggested_display_precision" not in entry4.options["sensor"] assert entry4.options["sensor"]["display_precision"] == 4 await hass.async_block_till_done() state = hass.states.get(entity4.entity_id) @@ -1479,9 +1557,10 @@ async def test_unit_conversion_priority_suggested_unit_change( # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity0.entity_id) assert entry.unit_of_measurement == original_unit - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": original_unit}, - } + assert ( + entry.options["sensor.private"]["suggested_unit_of_measurement"] + == original_unit + ) # Registered entity -> Follow suggested unit the first time the entity was seen state = hass.states.get(entity1.entity_id) @@ -1490,9 +1569,10 @@ async def test_unit_conversion_priority_suggested_unit_change( # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity1.entity_id) assert entry.unit_of_measurement == original_unit - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": original_unit}, - } + assert ( + entry.options["sensor.private"]["suggested_unit_of_measurement"] + == original_unit + ) @pytest.mark.parametrize( @@ -1574,9 +1654,10 @@ async def test_unit_conversion_priority_suggested_unit_change_2( # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity0.entity_id) assert entry.unit_of_measurement == native_unit_1 - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": native_unit_1}, - } + assert ( + entry.options["sensor.private"]["suggested_unit_of_measurement"] + == native_unit_1 + ) # Registered entity -> Follow unit in entity registry state = hass.states.get(entity1.entity_id) @@ -1585,9 +1666,89 @@ async def test_unit_conversion_priority_suggested_unit_change_2( # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity0.entity_id) assert entry.unit_of_measurement == native_unit_1 - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": native_unit_1}, - } + assert ( + entry.options["sensor.private"]["suggested_unit_of_measurement"] + == native_unit_1 + ) + + +@pytest.mark.parametrize( + ( + "device_class", + "native_unit", + "suggested_precision", + ), + [ + (SensorDeviceClass.APPARENT_POWER, UnitOfApparentPower.VOLT_AMPERE, 0), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_CENTIMETERS, 0), + (SensorDeviceClass.ATMOSPHERIC_PRESSURE, UnitOfPressure.PA, 0), + ( + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION, + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, + 0, + ), + (SensorDeviceClass.CONDUCTIVITY, UnitOfConductivity.MICROSIEMENS, 1), + (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.MILLIAMPERE, 0), + (SensorDeviceClass.DATA_RATE, UnitOfDataRate.KILOBITS_PER_SECOND, 0), + (SensorDeviceClass.DATA_SIZE, UnitOfInformation.KILOBITS, 0), + (SensorDeviceClass.DISTANCE, UnitOfLength.CENTIMETERS, 0), + (SensorDeviceClass.DURATION, UnitOfTime.MILLISECONDS, 0), + (SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 0), + ( + SensorDeviceClass.ENERGY_DISTANCE, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + 0, + ), + (SensorDeviceClass.ENERGY_STORAGE, UnitOfEnergy.WATT_HOUR, 0), + (SensorDeviceClass.FREQUENCY, UnitOfFrequency.HERTZ, 0), + (SensorDeviceClass.GAS, UnitOfVolume.MILLILITERS, 0), + (SensorDeviceClass.IRRADIANCE, UnitOfIrradiance.WATTS_PER_SQUARE_METER, 0), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 0), + (SensorDeviceClass.PRECIPITATION, UnitOfPrecipitationDepth.CENTIMETERS, 0), + ( + SensorDeviceClass.PRECIPITATION_INTENSITY, + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + 0, + ), + (SensorDeviceClass.PRESSURE, UnitOfPressure.PA, 0), + (SensorDeviceClass.REACTIVE_POWER, UnitOfReactivePower.VOLT_AMPERE_REACTIVE, 0), + (SensorDeviceClass.SOUND_PRESSURE, UnitOfSoundPressure.DECIBEL, 0), + (SensorDeviceClass.SPEED, UnitOfSpeed.MILLIMETERS_PER_SECOND, 0), + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.KELVIN, 1), + (SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 0), + (SensorDeviceClass.VOLUME, UnitOfVolume.MILLILITERS, 0), + (SensorDeviceClass.VOLUME_FLOW_RATE, UnitOfVolumeFlowRate.LITERS_PER_SECOND, 0), + (SensorDeviceClass.VOLUME_STORAGE, UnitOfVolume.MILLILITERS, 0), + (SensorDeviceClass.WATER, UnitOfVolume.MILLILITERS, 0), + (SensorDeviceClass.WEIGHT, UnitOfMass.GRAMS, 0), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.MILLIMETERS_PER_SECOND, 0), + ], +) +async def test_default_precision( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_class: str, + native_unit: str, + suggested_precision: int, +) -> None: + """Test default unit precision.""" + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + await hass.async_block_till_done() + + entity0 = MockSensor( + name="Test", + native_value="123", + native_unit_of_measurement=native_unit, + device_class=device_class, + unique_id="very_unique", + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entity0.entity_id) + assert entry.options["sensor"]["suggested_display_precision"] == suggested_precision @pytest.mark.parametrize( @@ -1756,39 +1917,6 @@ async def test_suggested_precision_option_update( } -async def test_suggested_precision_option_removal( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, -) -> None: - """Test suggested precision stored in the registry is removed.""" - # Pre-register entities - entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") - entity_registry.async_update_entity_options( - entry.entity_id, - "sensor", - { - "suggested_display_precision": 1, - }, - ) - - entity0 = MockSensor( - name="Test", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - native_value="1.5", - suggested_display_precision=None, - unique_id="very_unique", - ) - setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) - - assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) - await hass.async_block_till_done() - - # Assert the suggested precision is no longer stored in the registry - entry = entity_registry.async_get(entity0.entity_id) - assert entry.options.get("sensor", {}).get("suggested_display_precision") is None - - @pytest.mark.parametrize( ( "unit_system", @@ -1805,7 +1933,7 @@ async def test_suggested_precision_option_removal( UnitOfLength.KILOMETERS, UnitOfLength.MILES, 1000, - 621.0, + 621.3711, SensorDeviceClass.DISTANCE, ), ( @@ -1994,6 +2122,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( SensorDeviceClass.PRECIPITATION_INTENSITY, SensorDeviceClass.PRECIPITATION, SensorDeviceClass.PRESSURE, + SensorDeviceClass.REACTIVE_ENERGY, SensorDeviceClass.REACTIVE_POWER, SensorDeviceClass.SIGNAL_STRENGTH, SensorDeviceClass.SOUND_PRESSURE, @@ -2345,10 +2474,10 @@ async def test_numeric_state_expected_helper( UnitOfLength.METERS, UnitOfLength.YARDS, 1000, - "621", - "1000", - "1000000", - "1093613", + pytest.approx(621.3711), + 1000, + 1000000, + pytest.approx(1093613), SensorDeviceClass.DISTANCE, ), ], @@ -2438,40 +2567,40 @@ async def test_unit_conversion_update( # Registered entity -> Follow automatic unit conversion state = hass.states.get(entity0.entity_id) - assert state.state == automatic_state_1 + assert float(state.state) == automatic_state_1 assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit_1 # Assert the automatic unit conversion is stored in the registry entry = entity_registry.async_get(entity0.entity_id) - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": automatic_unit_1} + assert entry.options["sensor.private"] == { + "suggested_unit_of_measurement": automatic_unit_1 } state = hass.states.get(entity1.entity_id) - assert state.state == automatic_state_1 + assert float(state.state) == automatic_state_1 assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit_1 # Assert the automatic unit conversion is stored in the registry entry = entity_registry.async_get(entity1.entity_id) - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": automatic_unit_1} + assert entry.options["sensor.private"] == { + "suggested_unit_of_measurement": automatic_unit_1 } # Registered entity with suggested unit state = hass.states.get(entity2.entity_id) - assert state.state == suggested_state + assert float(state.state) == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity2.entity_id) - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": suggested_unit} + assert entry.options["sensor.private"] == { + "suggested_unit_of_measurement": suggested_unit } state = hass.states.get(entity3.entity_id) - assert state.state == suggested_state + assert float(state.state) == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity3.entity_id) - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": suggested_unit} + assert entry.options["sensor.private"] == { + "suggested_unit_of_measurement": suggested_unit } # Set a custom unit, this should have priority over the automatic unit conversion @@ -2481,7 +2610,7 @@ async def test_unit_conversion_update( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit entity_registry.async_update_entity_options( @@ -2490,7 +2619,7 @@ async def test_unit_conversion_update( await hass.async_block_till_done() state = hass.states.get(entity2.entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit # Change unit system, states and units should be unchanged @@ -2498,19 +2627,19 @@ async def test_unit_conversion_update( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit state = hass.states.get(entity1.entity_id) - assert state.state == automatic_state_1 + assert float(state.state) == automatic_state_1 assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit_1 state = hass.states.get(entity2.entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit state = hass.states.get(entity3.entity_id) - assert state.state == suggested_state + assert float(state.state) == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Update suggested unit @@ -2521,39 +2650,37 @@ async def test_unit_conversion_update( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit state = hass.states.get(entity1.entity_id) - assert state.state == automatic_state_2 + assert float(state.state) == automatic_state_2 assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit_2 state = hass.states.get(entity2.entity_id) - assert state.state == custom_state + assert float(state.state) == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit state = hass.states.get(entity3.entity_id) - assert state.state == suggested_state + assert float(state.state) == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Entity 4 still has a pending request to refresh entity options entry = entity_registry.async_get(entity4_entity_id) - assert entry.options == { - "sensor.private": { - "refresh_initial_entity_options": True, - "suggested_unit_of_measurement": automatic_unit_1, - } + assert entry.options["sensor.private"] == { + "refresh_initial_entity_options": True, + "suggested_unit_of_measurement": automatic_unit_1, } # Add entity 4, the pending request to refresh entity options should be handled await entity_platform.async_add_entities((entity4,)) state = hass.states.get(entity4_entity_id) - assert state.state == automatic_state_2 + assert float(state.state) == automatic_state_2 assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit_2 entry = entity_registry.async_get(entity4_entity_id) - assert entry.options == {} + assert "sensor.private" not in entry.options class MockFlow(ConfigFlow): @@ -2762,7 +2889,7 @@ async def test_suggested_unit_guard_invalid_unit( UnitOfTemperature.CELSIUS, 10, UnitOfTemperature.KELVIN, - 283, + 283.15, ), ( SensorDeviceClass.DATA_RATE, @@ -2808,6 +2935,57 @@ async def test_suggested_unit_guard_valid_unit( # Assert the suggested unit of measurement is stored in the registry entry = entity_registry.async_get(entity.entity_id) assert entry.unit_of_measurement == suggested_unit - assert entry.options == { - "sensor.private": {"suggested_unit_of_measurement": suggested_unit}, + assert entry.options["sensor.private"] == { + "suggested_unit_of_measurement": suggested_unit } + + +def test_device_class_units_are_complete() -> None: + """Test that the device class units enum is complete.""" + no_unit_device_classes = { + SensorDeviceClass.DATE, + SensorDeviceClass.ENUM, + SensorDeviceClass.MONETARY, + SensorDeviceClass.TIMESTAMP, + } + unit_device_classes = { + device_class.value for device_class in SensorDeviceClass + } - no_unit_device_classes + assert set(DEVICE_CLASS_UNITS.keys()) == unit_device_classes + + +def test_device_class_converters_are_complete() -> None: + """Test that the device class converters enum is complete.""" + no_converter_device_classes = { + SensorDeviceClass.APPARENT_POWER, + SensorDeviceClass.AQI, + SensorDeviceClass.BATTERY, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.DATE, + SensorDeviceClass.ENUM, + SensorDeviceClass.FREQUENCY, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.ILLUMINANCE, + SensorDeviceClass.IRRADIANCE, + SensorDeviceClass.MOISTURE, + SensorDeviceClass.MONETARY, + SensorDeviceClass.NITROGEN_DIOXIDE, + SensorDeviceClass.NITROGEN_MONOXIDE, + SensorDeviceClass.NITROUS_OXIDE, + SensorDeviceClass.OZONE, + SensorDeviceClass.PH, + SensorDeviceClass.PM1, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.REACTIVE_POWER, + SensorDeviceClass.SIGNAL_STRENGTH, + SensorDeviceClass.SOUND_PRESSURE, + SensorDeviceClass.SULPHUR_DIOXIDE, + SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.WIND_DIRECTION, + } + converter_device_classes = { + device_class.value for device_class in SensorDeviceClass + } - no_converter_device_classes + assert set(UNIT_CONVERTERS.keys()) == converter_device_classes diff --git a/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr b/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr index a78b012ac02..7992b82a4d3 100644 --- a/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr +++ b/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr @@ -23,6 +23,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -32,6 +35,7 @@ 'original_name': 'Altitude', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'altitude', 'unique_id': 'test-sensor-device-id-0_altitude', @@ -78,6 +82,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -87,6 +94,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-0_atmospheric_pressure', @@ -133,12 +141,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery voltage', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_voltage', 'unique_id': 'test-sensor-device-id-0_battery_voltage', @@ -185,12 +197,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Dew point', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dewpoint', 'unique_id': 'test-sensor-device-id-0_dewpoint', @@ -210,7 +226,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17.8', + 'state': '-17.7777777777778', }) # --- # name: test_sensors[sensor.test_sensor_name_0_humidity-entry] @@ -243,6 +259,7 @@ 'original_name': 'Humidity', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-0_humidity', @@ -295,6 +312,7 @@ 'original_name': 'Signal strength', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-0_signal_strength', @@ -341,12 +359,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-0_temperature', @@ -366,7 +388,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17.8', + 'state': '-17.7777777777778', }) # --- # name: test_sensors[sensor.test_sensor_name_0_vapor_pressure-entry] @@ -393,12 +415,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Vapor pressure', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vapor_pressure', 'unique_id': 'test-sensor-device-id-0_vapor_pressure', @@ -445,6 +471,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -454,6 +483,7 @@ 'original_name': 'Altitude', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'altitude', 'unique_id': 'test-sensor-device-id-1_altitude', @@ -500,6 +530,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -509,6 +542,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-1_atmospheric_pressure', @@ -555,12 +589,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery voltage', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_voltage', 'unique_id': 'test-sensor-device-id-1_battery_voltage', @@ -607,12 +645,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Dew point', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dewpoint', 'unique_id': 'test-sensor-device-id-1_dewpoint', @@ -632,7 +674,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17.8', + 'state': '-17.7777777777778', }) # --- # name: test_sensors[sensor.test_sensor_name_1_humidity-entry] @@ -665,6 +707,7 @@ 'original_name': 'Humidity', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-1_humidity', @@ -717,6 +760,7 @@ 'original_name': 'Signal strength', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-1_signal_strength', @@ -763,12 +807,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-1_temperature', @@ -788,7 +836,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17.8', + 'state': '-17.7777777777778', }) # --- # name: test_sensors[sensor.test_sensor_name_1_vapor_pressure-entry] @@ -815,12 +863,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Vapor pressure', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vapor_pressure', 'unique_id': 'test-sensor-device-id-1_vapor_pressure', @@ -867,6 +919,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -876,6 +931,7 @@ 'original_name': 'Altitude', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'altitude', 'unique_id': 'test-sensor-device-id-2_altitude', @@ -922,6 +978,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -931,6 +990,7 @@ 'original_name': 'Atmospheric pressure', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-2_atmospheric_pressure', @@ -977,12 +1037,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery voltage', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_voltage', 'unique_id': 'test-sensor-device-id-2_battery_voltage', @@ -1029,12 +1093,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Dew point', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dewpoint', 'unique_id': 'test-sensor-device-id-2_dewpoint', @@ -1054,7 +1122,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17.8', + 'state': '-17.7777777777778', }) # --- # name: test_sensors[sensor.test_sensor_name_2_humidity-entry] @@ -1087,6 +1155,7 @@ 'original_name': 'Humidity', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-2_humidity', @@ -1139,6 +1208,7 @@ 'original_name': 'Signal strength', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-2_signal_strength', @@ -1185,12 +1255,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-sensor-device-id-2_temperature', @@ -1210,7 +1284,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17.8', + 'state': '-17.7777777777778', }) # --- # name: test_sensors[sensor.test_sensor_name_2_vapor_pressure-entry] @@ -1237,12 +1311,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Vapor pressure', 'platform': 'sensorpush_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vapor_pressure', 'unique_id': 'test-sensor-device-id-2_vapor_pressure', diff --git a/tests/components/sensorpush_cloud/test_sensor.py b/tests/components/sensorpush_cloud/test_sensor.py index c35d40f1bc2..775fb788836 100644 --- a/tests/components/sensorpush_cloud/test_sensor.py +++ b/tests/components/sensorpush_cloud/test_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry diff --git a/tests/components/seventeentrack/test_services.py b/tests/components/seventeentrack/test_services.py index bbd5644ad63..2147ce994e0 100644 --- a/tests/components/seventeentrack/test_services.py +++ b/tests/components/seventeentrack/test_services.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.seventeentrack import DOMAIN from homeassistant.components.seventeentrack.const import ( diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index 4718abc02b5..0ee34eebf3f 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -63,6 +63,7 @@ 'original_name': 'WAN status', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wan_status', 'unique_id': 'e4:5d:51:00:11:22_wan_status', @@ -95,6 +96,7 @@ 'original_name': 'DSL status', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_status', 'unique_id': 'e4:5d:51:00:11:22_dsl_status', @@ -194,6 +196,7 @@ 'original_name': 'WAN status', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wan_status', 'unique_id': 'e4:5d:51:00:11:22_wan_status', @@ -226,6 +229,7 @@ 'original_name': 'FTTH status', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ftth_status', 'unique_id': 'e4:5d:51:00:11:22_ftth_status', diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index 68a1e7f7227..39dd9e512ae 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -63,6 +63,7 @@ 'original_name': 'Restart', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_reboot', diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 3ad7395caad..cd762a4b2ea 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -70,6 +70,7 @@ 'original_name': 'Network infrastructure', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'net_infra', 'unique_id': 'e4:5d:51:00:11:22_system_net_infra', @@ -104,6 +105,7 @@ 'original_name': 'Voltage', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_alimvoltage', @@ -138,6 +140,7 @@ 'original_name': 'Temperature', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_temperature', @@ -178,6 +181,7 @@ 'original_name': 'WAN mode', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wan_mode', 'unique_id': 'e4:5d:51:00:11:22_wan_mode', @@ -210,6 +214,7 @@ 'original_name': 'DSL line mode', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_linemode', 'unique_id': 'e4:5d:51:00:11:22_dsl_linemode', @@ -242,6 +247,7 @@ 'original_name': 'DSL counter', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_counter', 'unique_id': 'e4:5d:51:00:11:22_dsl_counter', @@ -274,6 +280,7 @@ 'original_name': 'DSL CRC', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_crc', 'unique_id': 'e4:5d:51:00:11:22_dsl_crc', @@ -308,6 +315,7 @@ 'original_name': 'DSL noise down', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_noise_down', 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_down', @@ -342,6 +350,7 @@ 'original_name': 'DSL noise up', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_noise_up', 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_up', @@ -376,6 +385,7 @@ 'original_name': 'DSL attenuation down', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_attenuation_down', 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_down', @@ -410,6 +420,7 @@ 'original_name': 'DSL attenuation up', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_attenuation_up', 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_up', @@ -438,12 +449,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DSL rate down', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_rate_down', 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_down', @@ -472,12 +487,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DSL rate up', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_rate_up', 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_up', @@ -519,6 +538,7 @@ 'original_name': 'DSL line status', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_line_status', 'unique_id': 'e4:5d:51:00:11:22_dsl_line_status', @@ -564,6 +584,7 @@ 'original_name': 'DSL training', 'platform': 'sfr_box', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dsl_training', 'unique_id': 'e4:5d:51:00:11:22_dsl_training', diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index ec2d3d2c829..6c835d2a636 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -53,7 +53,7 @@ async def init_integration( data[CONF_GEN] = gen entry = MockConfigEntry( - domain=DOMAIN, data=data, unique_id=MOCK_MAC, options=options + domain=DOMAIN, data=data, unique_id=MOCK_MAC, options=options, title="Test name" ) entry.add_to_hass(hass) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index dd17fe34cc8..4eccb075b67 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -189,7 +189,7 @@ MOCK_BLOCKS = [ ] MOCK_CONFIG = { - "input:0": {"id": 0, "name": "Test name input 0", "type": "button"}, + "input:0": {"id": 0, "name": "Test input 0", "type": "button"}, "input:1": { "id": 1, "type": "analog", @@ -204,7 +204,7 @@ MOCK_CONFIG = { "xcounts": {"expr": None, "unit": None}, "xfreq": {"expr": None, "unit": None}, }, - "flood:0": {"id": 0, "name": "Test name"}, + "flood:0": {"id": 0, "name": "Kitchen"}, "light:0": {"name": "test light_0"}, "light:1": {"name": "test light_1"}, "light:2": {"name": "test light_2"}, @@ -260,6 +260,33 @@ MOCK_BLU_TRV_REMOTE_CONFIG = { "meta": {}, }, }, + { + "key": "blutrv:201", + "status": { + "id": 201, + "target_C": 17.1, + "current_C": 17.1, + "pos": 0, + "rssi": -60, + "battery": 100, + "packet_id": 58, + "last_updated_ts": 1734967725, + "paired": True, + "rpc": True, + "rsv": 61, + }, + "config": { + "id": 201, + "addr": "f8:44:77:25:f0:de", + "name": "TRV-201", + "key": None, + "trv": "bthomedevice:201", + "temp_sensors": [], + "dw_sensors": [], + "override_delay": 30, + "meta": {}, + }, + }, ], "blutrv:200": { "id": 0, @@ -272,6 +299,17 @@ MOCK_BLU_TRV_REMOTE_CONFIG = { "name": "TRV-Name", "local_name": "SBTR-001AEU", }, + "blutrv:201": { + "id": 1, + "enable": True, + "min_valve_position": 0, + "default_boost_duration": 1800, + "default_override_duration": 2147483647, + "default_override_target_C": 8, + "addr": "f8:44:77:25:f0:de", + "name": "TRV-201", + "local_name": "SBTR-001AEU", + }, } @@ -287,6 +325,17 @@ MOCK_BLU_TRV_REMOTE_STATUS = { "battery": 100, "errors": [], }, + "blutrv:201": { + "id": 0, + "pos": 0, + "steps": 0, + "current_C": 15.2, + "target_C": 17.1, + "schedule_rev": 0, + "rssi": -60, + "battery": 100, + "errors": [], + }, } diff --git a/tests/components/shelly/fixtures/2pm_gen3.json b/tests/components/shelly/fixtures/2pm_gen3.json new file mode 100644 index 00000000000..bf3b4867585 --- /dev/null +++ b/tests/components/shelly/fixtures/2pm_gen3.json @@ -0,0 +1,259 @@ +{ + "config": { + "ble": { + "enable": true, + "rpc": { + "enable": true + } + }, + "bthome": {}, + "cloud": { + "enable": false, + "server": "iot.shelly.cloud:6012/jrpc" + }, + "input:0": { + "enable": true, + "factory_reset": true, + "id": 0, + "invert": false, + "name": null, + "type": "switch" + }, + "input:1": { + "enable": true, + "factory_reset": true, + "id": 1, + "invert": false, + "name": null, + "type": "switch" + }, + "knx": { + "enable": false, + "ia": "15.15.255", + "routing": { + "addr": "224.0.23.12:3671" + } + }, + "matter": { + "enable": false + }, + "mqtt": { + "client_id": "shelly2pmg3-aabbccddeeff", + "enable": true, + "enable_control": true, + "enable_rpc": true, + "rpc_ntf": true, + "server": "mqtt.test.server", + "ssl_ca": null, + "status_ntf": true, + "topic_prefix": "shelly2pmg3-aabbccddeeff", + "use_client_cert": false, + "user": "iot" + }, + "switch:0": { + "auto_off": false, + "auto_off_delay": 60.0, + "auto_on": false, + "auto_on_delay": 60.0, + "autorecover_voltage_errors": false, + "current_limit": 10.0, + "id": 0, + "in_locked": false, + "in_mode": "follow", + "initial_state": "match_input", + "name": null, + "power_limit": 2800, + "reverse": false, + "undervoltage_limit": 0, + "voltage_limit": 280 + }, + "switch:1": { + "auto_off": false, + "auto_off_delay": 60.0, + "auto_on": false, + "auto_on_delay": 60.0, + "autorecover_voltage_errors": false, + "current_limit": 10.0, + "id": 1, + "in_locked": false, + "in_mode": "follow", + "initial_state": "match_input", + "name": null, + "power_limit": 2800, + "reverse": false, + "undervoltage_limit": 0, + "voltage_limit": 280 + }, + "sys": { + "cfg_rev": 170, + "debug": { + "file_level": null, + "level": 2, + "mqtt": { + "enable": false + }, + "udp": { + "addr": null + }, + "websocket": { + "enable": true + } + }, + "device": { + "addon_type": null, + "discoverable": true, + "eco_mode": true, + "fw_id": "20250508-110823/1.6.1-g8dbd358", + "mac": "AABBCCDDEEFF", + "name": "Test Name", + "profile": "switch" + }, + "location": { + "lat": 15.2201, + "lon": 33.0121, + "tz": "Europe/Warsaw" + }, + "rpc_udp": { + "dst_addr": null, + "listen_port": null + }, + "sntp": { + "server": "sntp.test.server" + } + }, + "wifi": { + "sta": { + "ssid": "Wifi-Network-Name", + "is_open": false, + "enable": true, + "ipv4mode": "dhcp", + "ip": null, + "netmask": null, + "gw": null, + "nameserver": null + } + }, + "ws": { + "enable": false, + "server": null, + "ssl_ca": "ca.pem" + } + }, + "shelly": { + "app": "S2PMG3", + "auth_domain": null, + "auth_en": false, + "fw_id": "20250508-110823/1.6.1-g8dbd358", + "gen": 3, + "id": "shelly2pmg3-aabbccddeeff", + "mac": "AABBCCDDEEFF", + "matter": false, + "model": "S3SW-002P16EU", + "name": "Test Name", + "profile": "switch", + "slot": 0, + "ver": "1.6.1" + }, + "status": { + "ble": {}, + "bthome": {}, + "cloud": { + "connected": false + }, + "input:0": { + "id": 0, + "state": false + }, + "input:1": { + "id": 1, + "state": false + }, + "knx": {}, + "matter": { + "commissionable": false, + "num_fabrics": 0 + }, + "mqtt": { + "connected": true + }, + "switch:0": { + "aenergy": { + "by_minute": [0.0, 0.0, 0.0], + "minute_ts": 1747488720, + "total": 0.0 + }, + "apower": 0.0, + "current": 0.0, + "freq": 50.0, + "id": 0, + "output": false, + "pf": 0.0, + "ret_aenergy": { + "by_minute": [0.0, 0.0, 0.0], + "minute_ts": 1747488720, + "total": 0.0 + }, + "source": "init", + "temperature": { + "tC": 40.6, + "tF": 105.1 + }, + "voltage": 216.2 + }, + "switch:1": { + "aenergy": { + "by_minute": [0.0, 0.0, 0.0], + "minute_ts": 1747488720, + "total": 0.0 + }, + "apower": 0.0, + "current": 0.0, + "freq": 50.0, + "id": 1, + "output": false, + "pf": 0.0, + "ret_aenergy": { + "by_minute": [0.0, 0.0, 0.0], + "minute_ts": 1747488720, + "total": 0.0 + }, + "source": "init", + "temperature": { + "tC": 40.6, + "tF": 105.1 + }, + "voltage": 216.3 + }, + "sys": { + "available_updates": {}, + "btrelay_rev": 0, + "cfg_rev": 170, + "fs_free": 430080, + "fs_size": 917504, + "kvs_rev": 0, + "last_sync_ts": 1747488676, + "mac": "AABBCCDDEEFF", + "ram_free": 66440, + "ram_min_free": 49448, + "ram_size": 245788, + "reset_reason": 3, + "restart_required": false, + "schedule_rev": 22, + "time": "15:32", + "unixtime": 1747488776, + "uptime": 103, + "utc_offset": 7200, + "webhook_rev": 22 + }, + "wifi": { + "rssi": -52, + "ssid": "Wifi-Network-Name", + "sta_ip": "192.168.2.24", + "sta_ip6": [], + "status": "got ip" + }, + "ws": { + "connected": false + } + } +} diff --git a/tests/components/shelly/fixtures/2pm_gen3_cover.json b/tests/components/shelly/fixtures/2pm_gen3_cover.json new file mode 100644 index 00000000000..4aa2bad677e --- /dev/null +++ b/tests/components/shelly/fixtures/2pm_gen3_cover.json @@ -0,0 +1,242 @@ +{ + "config": { + "ble": { + "enable": true, + "rpc": { + "enable": true + } + }, + "bthome": {}, + "cloud": { + "enable": false, + "server": "iot.shelly.cloud:6012/jrpc" + }, + "cover:0": { + "current_limit": 10.0, + "id": 0, + "in_locked": false, + "in_mode": "dual", + "initial_state": "stopped", + "invert_directions": false, + "maintenance_mode": false, + "maxtime_close": 60.0, + "maxtime_open": 60.0, + "motor": { + "idle_confirm_period": 0.25, + "idle_power_thr": 2.0 + }, + "name": null, + "obstruction_detection": { + "action": "stop", + "direction": "both", + "enable": false, + "holdoff": 1.0, + "power_thr": 1000 + }, + "power_limit": 2800, + "safety_switch": { + "action": "stop", + "allowed_move": null, + "direction": "both", + "enable": false + }, + "slat": { + "close_time": 1.5, + "enable": false, + "open_time": 1.5, + "precise_ctl": false, + "retain_pos": false, + "step": 20 + }, + "swap_inputs": false, + "undervoltage_limit": 0, + "voltage_limit": 280 + }, + "input:0": { + "enable": true, + "factory_reset": true, + "id": 0, + "invert": false, + "name": null, + "type": "switch" + }, + "input:1": { + "enable": true, + "factory_reset": true, + "id": 1, + "invert": false, + "name": null, + "type": "switch" + }, + "knx": { + "enable": false, + "ia": "15.15.255", + "routing": { + "addr": "224.0.23.12:3671" + } + }, + "matter": { + "enable": false + }, + "mqtt": { + "client_id": "shelly2pmg3-aabbccddeeff", + "enable": true, + "enable_control": true, + "enable_rpc": true, + "rpc_ntf": true, + "server": "mqtt.test.server", + "ssl_ca": null, + "status_ntf": true, + "topic_prefix": "shellies-gen3/shelly-2pm-gen3-365730", + "use_client_cert": false, + "user": "iot" + }, + "sys": { + "cfg_rev": 171, + "debug": { + "file_level": null, + "level": 2, + "mqtt": { + "enable": false + }, + "udp": { + "addr": null + }, + "websocket": { + "enable": true + } + }, + "device": { + "addon_type": null, + "discoverable": true, + "eco_mode": true, + "fw_id": "20250508-110823/1.6.1-g8dbd358", + "mac": "AABBCCDDEEFF", + "name": "Test Name", + "profile": "cover" + }, + "location": { + "lat": 19.2201, + "lon": 34.0121, + "tz": "Europe/Warsaw" + }, + "rpc_udp": { + "dst_addr": null, + "listen_port": null + }, + "sntp": { + "server": "sntp.test.server" + }, + "ui_data": { + "consumption_types": ["", "light"] + } + }, + "wifi": { + "sta": { + "ssid": "Wifi-Network-Name", + "is_open": false, + "enable": true, + "ipv4mode": "dhcp", + "ip": null, + "netmask": null, + "gw": null, + "nameserver": null + } + }, + "ws": { + "enable": false, + "server": null, + "ssl_ca": "ca.pem" + } + }, + "shelly": { + "app": "S2PMG3", + "auth_domain": null, + "auth_en": false, + "fw_id": "20250508-110823/1.6.1-g8dbd358", + "gen": 3, + "id": "shelly2pmg3-aabbccddeeff", + "mac": "AABBCCDDEEFF", + "matter": false, + "model": "S3SW-002P16EU", + "name": "Test Name", + "profile": "cover", + "slot": 0, + "ver": "1.6.1" + }, + "status": { + "ble": {}, + "bthome": {}, + "cloud": { + "connected": false + }, + "cover:0": { + "aenergy": { + "by_minute": [0.0, 0.0, 0.0], + "minute_ts": 1747492440, + "total": 0.0 + }, + "apower": 0.0, + "current": 0.0, + "freq": 50.0, + "id": 0, + "last_direction": null, + "pf": 0.0, + "pos_control": false, + "source": "init", + "state": "stopped", + "temperature": { + "tC": 36.4, + "tF": 97.5 + }, + "voltage": 217.7 + }, + "input:0": { + "id": 0, + "state": false + }, + "input:1": { + "id": 1, + "state": false + }, + "knx": {}, + "matter": { + "commissionable": false, + "num_fabrics": 0 + }, + "mqtt": { + "connected": true + }, + "sys": { + "available_updates": {}, + "btrelay_rev": 0, + "cfg_rev": 171, + "fs_free": 430080, + "fs_size": 917504, + "kvs_rev": 0, + "last_sync_ts": 1747492085, + "mac": "AABBCCDDEEFF", + "ram_free": 64632, + "ram_min_free": 51660, + "ram_size": 245568, + "reset_reason": 3, + "restart_required": false, + "schedule_rev": 23, + "time": "16:34", + "unixtime": 1747492463, + "uptime": 381, + "utc_offset": 7200, + "webhook_rev": 23 + }, + "wifi": { + "rssi": -53, + "ssid": "Wifi-Network-Name", + "sta_ip": "192.168.2.24", + "sta_ip6": [], + "status": "got ip" + }, + "ws": { + "connected": false + } + } +} diff --git a/tests/components/shelly/fixtures/pro_3em.json b/tests/components/shelly/fixtures/pro_3em.json new file mode 100644 index 00000000000..93351e9bc65 --- /dev/null +++ b/tests/components/shelly/fixtures/pro_3em.json @@ -0,0 +1,216 @@ +{ + "config": { + "ble": { + "enable": false, + "rpc": { + "enable": true + } + }, + "bthome": {}, + "cloud": { + "enable": false, + "server": "iot.shelly.cloud:6012/jrpc" + }, + "em:0": { + "blink_mode_selector": "active_energy", + "ct_type": "120A", + "id": 0, + "monitor_phase_sequence": false, + "name": null, + "phase_selector": "all", + "reverse": {} + }, + "emdata:0": {}, + "eth": { + "enable": false, + "gw": null, + "ip": null, + "ipv4mode": "dhcp", + "nameserver": null, + "netmask": null, + "server_mode": false + }, + "modbus": { + "enable": true + }, + "mqtt": { + "client_id": "shellypro3em-aabbccddeeff", + "enable": false, + "enable_control": true, + "enable_rpc": true, + "rpc_ntf": true, + "server": "mqtt.test.server", + "ssl_ca": null, + "status_ntf": true, + "topic_prefix": "shellypro3em-aabbccddeeff", + "use_client_cert": false, + "user": "iot" + }, + "sys": { + "cfg_rev": 50, + "debug": { + "file_level": null, + "level": 2, + "mqtt": { + "enable": false + }, + "udp": { + "addr": null + }, + "websocket": { + "enable": false + } + }, + "device": { + "addon_type": null, + "discoverable": true, + "eco_mode": false, + "fw_id": "20250508-110717/1.6.1-g8dbd358", + "mac": "AABBCCDDEEFF", + "name": "Test Name", + "profile": "triphase", + "sys_btn_toggle": true + }, + "location": { + "lat": 22.55775, + "lon": 54.94637, + "tz": "Europe/Warsaw" + }, + "rpc_udp": { + "dst_addr": null, + "listen_port": null + }, + "sntp": { + "server": "sntp.test.server" + }, + "ui_data": {} + }, + "temperature:0": { + "id": 0, + "name": null, + "offset_C": 0.0, + "report_thr_C": 5.0 + }, + "wifi": { + "sta": { + "ssid": "Wifi-Network-Name", + "is_open": false, + "enable": true, + "ipv4mode": "dhcp", + "ip": null, + "netmask": null, + "gw": null, + "nameserver": null + } + }, + "ws": { + "enable": false, + "server": null, + "ssl_ca": "ca.pem" + } + }, + "shelly": { + "app": "Pro3EM", + "auth_domain": "shellypro3em-aabbccddeeff", + "auth_en": true, + "fw_id": "20250508-110717/1.6.1-g8dbd358", + "gen": 2, + "id": "shellypro3em-aabbccddeeff", + "mac": "AABBCCDDEEFF", + "model": "SPEM-003CEBEU", + "name": "Test Name", + "profile": "triphase", + "slot": 0, + "ver": "1.6.1" + }, + "status": { + "ble": {}, + "bthome": { + "errors": ["bluetooth_disabled"] + }, + "cloud": { + "connected": false + }, + "em:0": { + "a_act_power": 2166.2, + "a_aprt_power": 2175.9, + "a_current": 9.592, + "a_freq": 49.9, + "a_pf": 0.99, + "a_voltage": 227.0, + "b_act_power": 3.6, + "b_aprt_power": 10.1, + "b_current": 0.044, + "b_freq": 49.9, + "b_pf": 0.36, + "b_voltage": 230.0, + "c_act_power": 244.0, + "c_aprt_power": 339.7, + "c_current": 1.479, + "c_freq": 49.9, + "c_pf": 0.72, + "c_voltage": 230.2, + "id": 0, + "n_current": null, + "total_act_power": 2413.825, + "total_aprt_power": 2525.779, + "total_current": 11.116, + "user_calibrated_phase": [] + }, + "emdata:0": { + "a_total_act_energy": 3105576.42, + "a_total_act_ret_energy": 0.0, + "b_total_act_energy": 195765.72, + "b_total_act_ret_energy": 0.0, + "c_total_act_energy": 2114072.05, + "c_total_act_ret_energy": 0.0, + "id": 0, + "total_act": 5415414.19, + "total_act_ret": 0.0 + }, + "eth": { + "ip": null, + "ip6": null + }, + "modbus": {}, + "mqtt": { + "connected": false + }, + "sys": { + "available_updates": {}, + "btrelay_rev": 0, + "cfg_rev": 50, + "fs_free": 180224, + "fs_size": 524288, + "kvs_rev": 1, + "last_sync_ts": 1747561099, + "mac": "AABBCCDDEEFF", + "ram_free": 113080, + "ram_min_free": 97524, + "ram_size": 247524, + "reset_reason": 3, + "restart_required": false, + "schedule_rev": 0, + "time": "11:38", + "unixtime": 1747561101, + "uptime": 501683, + "utc_offset": 7200, + "webhook_rev": 0 + }, + "temperature:0": { + "id": 0, + "tC": 46.3, + "tF": 115.4 + }, + "wifi": { + "rssi": -57, + "ssid": "Wifi-Network-Name", + "sta_ip": "192.168.2.151", + "sta_ip6": [], + "status": "got ip" + }, + "ws": { + "connected": false + } + } +} diff --git a/tests/components/shelly/snapshots/test_binary_sensor.ambr b/tests/components/shelly/snapshots/test_binary_sensor.ambr index fcc6377837e..201f20c3de9 100644 --- a/tests/components/shelly/snapshots/test_binary_sensor.ambr +++ b/tests/components/shelly/snapshots/test_binary_sensor.ambr @@ -13,7 +13,7 @@ 'domain': 'binary_sensor', 'entity_category': , 'entity_id': 'binary_sensor.trv_name_calibration', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,9 +24,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'TRV-Name calibration', + 'original_name': 'Calibration', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-blutrv:200-calibration', @@ -37,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'TRV-Name calibration', + 'friendly_name': 'TRV-Name Calibration', }), 'context': , 'entity_id': 'binary_sensor.trv_name_calibration', @@ -47,7 +48,7 @@ 'state': 'off', }) # --- -# name: test_rpc_flood_entities[binary_sensor.test_name_flood-entry] +# name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_flood-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,8 +61,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.test_name_flood', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.test_name_kitchen_flood', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -72,30 +73,31 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Test name flood', + 'original_name': 'Kitchen flood', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-flood:0-flood', 'unit_of_measurement': None, }) # --- -# name: test_rpc_flood_entities[binary_sensor.test_name_flood-state] +# name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_flood-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'moisture', - 'friendly_name': 'Test name flood', + 'friendly_name': 'Test name Kitchen flood', }), 'context': , - 'entity_id': 'binary_sensor.test_name_flood', + 'entity_id': 'binary_sensor.test_name_kitchen_flood', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_rpc_flood_entities[binary_sensor.test_name_mute-entry] +# name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_mute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,8 +110,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_name_mute', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.test_name_kitchen_mute', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -120,22 +122,23 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Test name mute', + 'original_name': 'Kitchen mute', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-flood:0-mute', 'unit_of_measurement': None, }) # --- -# name: test_rpc_flood_entities[binary_sensor.test_name_mute-state] +# name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_mute-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test name mute', + 'friendly_name': 'Test name Kitchen mute', }), 'context': , - 'entity_id': 'binary_sensor.test_name_mute', + 'entity_id': 'binary_sensor.test_name_kitchen_mute', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/shelly/snapshots/test_button.ambr b/tests/components/shelly/snapshots/test_button.ambr index f5a38f1b847..09c2c5f3d8d 100644 --- a/tests/components/shelly/snapshots/test_button.ambr +++ b/tests/components/shelly/snapshots/test_button.ambr @@ -13,7 +13,7 @@ 'domain': 'button', 'entity_category': , 'entity_id': 'button.trv_name_calibrate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,9 +24,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'TRV-Name Calibrate', + 'original_name': 'Calibrate', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calibrate', 'unique_id': 'f8:44:77:25:f0:dd_calibrate', @@ -60,7 +61,7 @@ 'domain': 'button', 'entity_category': , 'entity_id': 'button.test_name_reboot', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -71,9 +72,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Test name Reboot', + 'original_name': 'Reboot', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC_reboot', diff --git a/tests/components/shelly/snapshots/test_climate.ambr b/tests/components/shelly/snapshots/test_climate.ambr index 991c570172e..35746dd5c08 100644 --- a/tests/components/shelly/snapshots/test_climate.ambr +++ b/tests/components/shelly/snapshots/test_climate.ambr @@ -34,6 +34,7 @@ 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'f8:44:77:25:f0:dd-blutrv:200', @@ -90,7 +91,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.test_name', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -101,9 +102,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Test name', + 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sensor_0', @@ -140,7 +142,7 @@ 'state': 'off', }) # --- -# name: test_rpc_climate_hvac_mode[climate.test_name_thermostat_0-entry] +# name: test_rpc_climate_hvac_mode[climate.test_name-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -161,8 +163,8 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.test_name_thermostat_0', - 'has_entity_name': False, + 'entity_id': 'climate.test_name', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -173,21 +175,22 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Test name Thermostat 0', + 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-thermostat:0', 'unit_of_measurement': None, }) # --- -# name: test_rpc_climate_hvac_mode[climate.test_name_thermostat_0-state] +# name: test_rpc_climate_hvac_mode[climate.test_name-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 44.4, 'current_temperature': 12.3, - 'friendly_name': 'Test name Thermostat 0', + 'friendly_name': 'Test name', 'hvac_action': , 'hvac_modes': list([ , @@ -200,14 +203,14 @@ 'temperature': 23, }), 'context': , - 'entity_id': 'climate.test_name_thermostat_0', + 'entity_id': 'climate.test_name', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'heat', }) # --- -# name: test_wall_display_thermostat_mode[climate.test_name_thermostat_0-entry] +# name: test_wall_display_thermostat_mode[climate.test_name-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -228,8 +231,8 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.test_name_thermostat_0', - 'has_entity_name': False, + 'entity_id': 'climate.test_name', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -240,21 +243,22 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Test name Thermostat 0', + 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-thermostat:0', 'unit_of_measurement': None, }) # --- -# name: test_wall_display_thermostat_mode[climate.test_name_thermostat_0-state] +# name: test_wall_display_thermostat_mode[climate.test_name-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 44.4, 'current_temperature': 12.3, - 'friendly_name': 'Test name Thermostat 0', + 'friendly_name': 'Test name', 'hvac_action': , 'hvac_modes': list([ , @@ -267,7 +271,7 @@ 'temperature': 23, }), 'context': , - 'entity_id': 'climate.test_name_thermostat_0', + 'entity_id': 'climate.test_name', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/shelly/snapshots/test_event.ambr b/tests/components/shelly/snapshots/test_event.ambr index ae719774aee..b87436ba4aa 100644 --- a/tests/components/shelly/snapshots/test_event.ambr +++ b/tests/components/shelly/snapshots/test_event.ambr @@ -32,6 +32,7 @@ 'original_name': 'test_script.js', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'script', 'unique_id': '123456789ABC-script:1', diff --git a/tests/components/shelly/snapshots/test_number.ambr b/tests/components/shelly/snapshots/test_number.ambr index 07fda999556..138a0148ecb 100644 --- a/tests/components/shelly/snapshots/test_number.ambr +++ b/tests/components/shelly/snapshots/test_number.ambr @@ -18,7 +18,7 @@ 'domain': 'number', 'entity_category': , 'entity_id': 'number.trv_name_external_temperature', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -29,9 +29,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'TRV-Name external temperature', + 'original_name': 'External temperature', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'external_temperature', 'unique_id': '123456789ABC-blutrv:200-external_temperature', @@ -41,7 +42,7 @@ # name: test_blu_trv_number_entity[number.trv_name_external_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'TRV-Name external temperature', + 'friendly_name': 'TRV-Name External temperature', 'max': 50, 'min': -50, 'mode': , @@ -75,7 +76,7 @@ 'domain': 'number', 'entity_category': None, 'entity_id': 'number.trv_name_valve_position', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -86,9 +87,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'TRV-Name valve position', + 'original_name': 'Valve position', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve_position', 'unique_id': '123456789ABC-blutrv:200-valve_position', @@ -98,7 +100,7 @@ # name: test_blu_trv_number_entity[number.trv_name_valve_position-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'TRV-Name valve position', + 'friendly_name': 'TRV-Name Valve position', 'max': 100, 'min': 0, 'mode': , diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr index cb39b148c8a..4b12dddae62 100644 --- a/tests/components/shelly/snapshots/test_sensor.ambr +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -15,7 +15,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.trv_name_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -26,9 +26,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'TRV-Name battery', + 'original_name': 'Battery', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-blutrv:200-blutrv_battery', @@ -39,7 +40,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'TRV-Name battery', + 'friendly_name': 'TRV-Name Battery', 'state_class': , 'unit_of_measurement': '%', }), @@ -67,7 +68,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.trv_name_signal_strength', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -78,9 +79,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'TRV-Name signal strength', + 'original_name': 'Signal strength', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-blutrv:200-blutrv_rssi', @@ -91,7 +93,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'signal_strength', - 'friendly_name': 'TRV-Name signal strength', + 'friendly_name': 'TRV-Name Signal strength', 'state_class': , 'unit_of_measurement': 'dBm', }), @@ -119,7 +121,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.trv_name_valve_position', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -130,9 +132,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'TRV-Name valve position', + 'original_name': 'Valve position', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'valve_position', 'unique_id': '123456789ABC-blutrv:200-valve_position', @@ -142,7 +145,7 @@ # name: test_blu_trv_sensor_entity[sensor.trv_name_valve_position-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'TRV-Name valve position', + 'friendly_name': 'TRV-Name Valve position', 'state_class': , 'unit_of_measurement': '%', }), @@ -154,3 +157,121 @@ 'state': '0', }) # --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_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.test_name_test_switch_0_energy', + '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': 'test switch_0 energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name test switch_0 energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_test_switch_0_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.56789', + }) +# --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_returned_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.test_name_test_switch_0_returned_energy', + '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': 'test switch_0 returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name test switch_0 returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_test_switch_0_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98.76543', + }) +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index ea3a7d5f3d2..f67e0bbb564 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import Mock from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_MOTION from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.shelly.const import UPDATE_PERIOD_MULTIPLIER @@ -36,7 +36,8 @@ async def test_block_binary_sensor( entity_registry: EntityRegistry, ) -> None: """Test block binary sensor.""" - entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_channel_1_overpowering" + monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_overpowering" await init_integration(hass, 1) assert (state := hass.states.get(entity_id)) @@ -239,7 +240,7 @@ async def test_rpc_binary_sensor( entity_registry: EntityRegistry, ) -> None: """Test RPC binary sensor.""" - entity_id = f"{BINARY_SENSOR_DOMAIN}.test_cover_0_overpowering" + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_test_cover_0_overpowering" await init_integration(hass, 2) assert (state := hass.states.get(entity_id)) @@ -521,7 +522,7 @@ async def test_rpc_flood_entities( await init_integration(hass, 4) for entity in ("flood", "mute"): - entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_{entity}" + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_kitchen_{entity}" state = hass.states.get(entity_id) assert state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 2057076d18b..8d355098463 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -5,7 +5,7 @@ from unittest.mock import Mock from aioshelly.const import MODEL_BLU_GATEWAY_G3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.shelly.const import DOMAIN diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 81914bb6a90..c19bd916fed 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -11,7 +11,7 @@ from aioshelly.const import ( ) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -613,7 +613,7 @@ async def test_rpc_climate_hvac_mode( snapshot: SnapshotAssertion, ) -> None: """Test climate hvac mode service.""" - entity_id = "climate.test_name_thermostat_0" + entity_id = "climate.test_name" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -651,7 +651,7 @@ async def test_rpc_climate_without_humidity( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test climate entity without the humidity value.""" - entity_id = "climate.test_name_thermostat_0" + entity_id = "climate.test_name" new_status = deepcopy(mock_rpc_device.status) new_status.pop("humidity:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) @@ -673,7 +673,7 @@ async def test_rpc_climate_set_temperature( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test climate set target temperature.""" - entity_id = "climate.test_name_thermostat_0" + entity_id = "climate.test_name" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -700,7 +700,7 @@ async def test_rpc_climate_hvac_mode_cool( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test climate with hvac mode cooling.""" - entity_id = "climate.test_name_thermostat_0" + entity_id = "climate.test_name" new_config = deepcopy(mock_rpc_device.config) new_config["thermostat:0"]["type"] = "cooling" monkeypatch.setattr(mock_rpc_device, "config", new_config) @@ -720,8 +720,8 @@ async def test_wall_display_thermostat_mode( snapshot: SnapshotAssertion, ) -> None: """Test Wall Display in thermostat mode.""" - climate_entity_id = "climate.test_name_thermostat_0" - switch_entity_id = "switch.test_switch_0" + climate_entity_id = "climate.test_name" + switch_entity_id = "switch.test_name_test_switch_0" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -745,8 +745,8 @@ async def test_wall_display_thermostat_mode_external_actuator( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test Wall Display in thermostat mode with an external actuator.""" - climate_entity_id = "climate.test_name_thermostat_0" - switch_entity_id = "switch.test_switch_0" + climate_entity_id = "climate.test_name" + switch_entity_id = "switch.test_name_test_switch_0" new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index cf7f82014a0..5b4372fe938 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -56,6 +56,8 @@ async def test_block_reload_on_cfg_change( ) -> None: """Test block reload on config change.""" await init_integration(hass, 1) + # num_outputs is 2, devicename and channel name is used + entity_id = "switch.test_name_channel_1" monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 1) mock_block_device.mock_update() @@ -71,7 +73,7 @@ async def test_block_reload_on_cfg_change( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") + assert hass.states.get(entity_id) # Generate config change from switch to light monkeypatch.setitem( @@ -81,14 +83,14 @@ async def test_block_reload_on_cfg_change( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") + assert hass.states.get(entity_id) # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is None + assert hass.states.get(entity_id) is None async def test_block_no_reload_on_bulb_changes( @@ -98,6 +100,9 @@ async def test_block_no_reload_on_bulb_changes( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block no reload on bulb mode/effect change.""" + monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) + # num_outputs is 1, device name is used + entity_id = "switch.test_name" await init_integration(hass, 1, model=MODEL_BULB) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 1) @@ -113,14 +118,14 @@ async def test_block_no_reload_on_bulb_changes( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") + assert hass.states.get(entity_id) # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") + assert hass.states.get(entity_id) # Test no reload on effect change monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "effect", 1) @@ -128,14 +133,14 @@ async def test_block_no_reload_on_bulb_changes( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") + assert hass.states.get(entity_id) # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") + assert hass.states.get(entity_id) async def test_block_polling_auth_error( @@ -242,9 +247,11 @@ async def test_block_polling_connection_error( "update", AsyncMock(side_effect=DeviceConnectionError), ) + # num_outputs is 2, device name and channel name is used + entity_id = "switch.test_name_channel_1" await init_integration(hass, 1) - assert (state := hass.states.get("switch.test_name_channel_1")) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON # Move time to generate polling @@ -252,7 +259,7 @@ async def test_block_polling_connection_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert (state := hass.states.get("switch.test_name_channel_1")) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE @@ -391,6 +398,7 @@ async def test_rpc_reload_on_cfg_change( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC reload on config change.""" + entity_id = "switch.test_name_test_switch_0" monkeypatch.delitem(mock_rpc_device.status, "cover:0") monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) @@ -421,14 +429,14 @@ async def test_rpc_reload_on_cfg_change( ) await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0") + assert hass.states.get(entity_id) # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0") is None + assert hass.states.get(entity_id) is None async def test_rpc_reload_with_invalid_auth( @@ -719,11 +727,12 @@ async def test_rpc_reconnect_error( exc: Exception, ) -> None: """Test RPC reconnect error.""" + entity_id = "switch.test_name_test_switch_0" monkeypatch.delitem(mock_rpc_device.status, "cover:0") monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - assert (state := hass.states.get("switch.test_switch_0")) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON monkeypatch.setattr(mock_rpc_device, "connected", False) @@ -734,7 +743,7 @@ async def test_rpc_reconnect_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert (state := hass.states.get("switch.test_switch_0")) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE @@ -746,6 +755,7 @@ async def test_rpc_error_running_connected_events( caplog: pytest.LogCaptureFixture, ) -> None: """Test RPC error while running connected events.""" + entity_id = "switch.test_name_test_switch_0" monkeypatch.delitem(mock_rpc_device.status, "cover:0") monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) with patch( @@ -758,7 +768,7 @@ async def test_rpc_error_running_connected_events( assert "Error running connected events for device" in caplog.text - assert (state := hass.states.get("switch.test_switch_0")) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE # Move time to generate reconnect without error @@ -766,7 +776,7 @@ async def test_rpc_error_running_connected_events( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert (state := hass.states.get("switch.test_switch_0")) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index df3ab4f288d..4f8e8a7650d 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -116,7 +116,7 @@ async def test_rpc_device_services( entity_registry: EntityRegistry, ) -> None: """Test RPC device cover services.""" - entity_id = "cover.test_cover_0" + entity_id = "cover.test_name_test_cover_0" await init_integration(hass, 2) await hass.services.async_call( @@ -178,23 +178,24 @@ async def test_rpc_device_no_cover_keys( monkeypatch.delitem(mock_rpc_device.status, "cover:0") await init_integration(hass, 2) - assert hass.states.get("cover.test_cover_0") is None + assert hass.states.get("cover.test_name_test_cover_0") is None async def test_rpc_device_update( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test RPC device update.""" + entity_id = "cover.test_name_test_cover_0" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") await init_integration(hass, 2) - state = hass.states.get("cover.test_cover_0") + state = hass.states.get(entity_id) assert state assert state.state == CoverState.CLOSED mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "open") mock_rpc_device.mock_update() - state = hass.states.get("cover.test_cover_0") + state = hass.states.get(entity_id) assert state assert state.state == CoverState.OPEN @@ -208,7 +209,7 @@ async def test_rpc_device_no_position_control( ) await init_integration(hass, 2) - state = hass.states.get("cover.test_cover_0") + state = hass.states.get("cover.test_name_test_cover_0") assert state assert state.state == CoverState.OPEN @@ -220,7 +221,7 @@ async def test_rpc_cover_tilt( entity_registry: EntityRegistry, ) -> None: """Test RPC cover that supports tilt.""" - entity_id = "cover.test_cover_0" + entity_id = "cover.test_name_test_cover_0" config = deepcopy(mock_rpc_device.config) config["cover:0"]["slat"] = {"enable": True} diff --git a/tests/components/shelly/test_devices.py b/tests/components/shelly/test_devices.py new file mode 100644 index 00000000000..e894a393ac5 --- /dev/null +++ b/tests/components/shelly/test_devices.py @@ -0,0 +1,479 @@ +"""Test real devices.""" + +from unittest.mock import Mock + +from aioshelly.const import MODEL_2PM_G3, MODEL_PRO_EM3 +import pytest + +from homeassistant.components.shelly.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import init_integration + +from tests.common import load_json_object_fixture + + +async def test_shelly_2pm_gen3_no_relay_names( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly 2PM Gen3 without relay names. + + This device has two relays/channels,we should get a main device and two sub + devices. + """ + device_fixture = load_json_object_fixture("2pm_gen3.json", DOMAIN) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, gen=3, model=MODEL_2PM_G3) + + # Relay 0 sub-device + entity_id = "switch.test_name_switch_0" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Switch 0" + + entity_id = "sensor.test_name_switch_0_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Switch 0" + + # Relay 1 sub-device + entity_id = "switch.test_name_switch_1" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Switch 1" + + entity_id = "sensor.test_name_switch_1_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Switch 1" + + # Main device + entity_id = "update.test_name_firmware" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + +async def test_shelly_2pm_gen3_relay_names( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly 2PM Gen3 with relay names. + + This device has two relays/channels,we should get a main device and two sub + devices. + """ + device_fixture = load_json_object_fixture("2pm_gen3.json", DOMAIN) + device_fixture["config"]["switch:0"]["name"] = "Kitchen light" + device_fixture["config"]["switch:1"]["name"] = "Living room light" + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, gen=3, model=MODEL_2PM_G3) + + # Relay 0 sub-device + entity_id = "switch.kitchen_light" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Kitchen light" + + entity_id = "sensor.kitchen_light_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Kitchen light" + + # Relay 1 sub-device + entity_id = "switch.living_room_light" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Living room light" + + entity_id = "sensor.living_room_light_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Living room light" + + # Main device + entity_id = "update.test_name_firmware" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + +async def test_shelly_2pm_gen3_cover( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly 2PM Gen3 with cover profile. + + With the cover profile we should only get the main device and no subdevices. + """ + device_fixture = load_json_object_fixture("2pm_gen3_cover.json", DOMAIN) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, gen=3, model=MODEL_2PM_G3) + + entity_id = "cover.test_name" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + entity_id = "sensor.test_name_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + entity_id = "update.test_name_firmware" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + +async def test_shelly_2pm_gen3_cover_with_name( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly 2PM Gen3 with cover profile and the cover name. + + With the cover profile we should only get the main device and no subdevices. + """ + device_fixture = load_json_object_fixture("2pm_gen3_cover.json", DOMAIN) + device_fixture["config"]["cover:0"]["name"] = "Bedroom blinds" + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, gen=3, model=MODEL_2PM_G3) + + entity_id = "cover.test_name_bedroom_blinds" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + entity_id = "sensor.test_name_bedroom_blinds_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + entity_id = "update.test_name_firmware" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + +async def test_shelly_pro_3em( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly Pro 3EM. + + We should get the main device and three subdevices, one subdevice per one phase. + """ + device_fixture = load_json_object_fixture("pro_3em.json", DOMAIN) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, gen=2, model=MODEL_PRO_EM3) + + # Main device + entity_id = "sensor.test_name_total_active_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + # Phase A sub-device + entity_id = "sensor.test_name_phase_a_active_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Phase A" + + # Phase B sub-device + entity_id = "sensor.test_name_phase_b_active_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Phase B" + + # Phase C sub-device + entity_id = "sensor.test_name_phase_c_active_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Phase C" + + +async def test_shelly_pro_3em_with_emeter_name( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly Pro 3EM when the name for Emeter is set. + + We should get the main device and three subdevices, one subdevice per one phase. + """ + device_fixture = load_json_object_fixture("pro_3em.json", DOMAIN) + device_fixture["config"]["em:0"]["name"] = "Emeter name" + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, gen=2, model=MODEL_PRO_EM3) + + # Main device + entity_id = "sensor.test_name_total_active_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + # Phase A sub-device + entity_id = "sensor.test_name_phase_a_active_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Phase A" + + # Phase B sub-device + entity_id = "sensor.test_name_phase_b_active_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Phase B" + + # Phase C sub-device + entity_id = "sensor.test_name_phase_c_active_power" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name Phase C" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_block_channel_with_name( + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, +) -> None: + """Test block channel with name.""" + monkeypatch.setitem( + mock_block_device.settings["relays"][0], "name", "Kitchen light" + ) + + await init_integration(hass, 1) + + # channel 1 sub-device; num_outputs is 2 so the name of the channel should be used + entity_id = "switch.kitchen_light" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Kitchen light" + + # main device + entity_id = "update.test_name_firmware" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 84ebd50c425..6bd44fa036a 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -103,7 +103,6 @@ async def test_rpc_config_entry_diagnostics( ) result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result == { "entry": entry_dict | {"discovery_keys": {}}, "bluetooth": { @@ -147,11 +146,17 @@ async def test_rpc_config_entry_diagnostics( ], "last_detection": ANY, "monotonic_time": ANY, - "name": "Mock Title (12:34:56:78:9A:BE)", + "name": "Test name (12:34:56:78:9A:BE)", "scanning": True, "start_time": ANY, "source": "12:34:56:78:9A:BE", "time_since_last_device_detection": {"AA:BB:CC:DD:EE:FF": ANY}, + "raw_advertisement_data": { + "AA:BB:CC:DD:EE:FF": { + "__type": "", + "repr": "b'\\x02\\x01\\x06\\t\\xffY\\x00\\xd1\\xfb;t\\xc8\\x90\\x11\\x07\\x1b\\xc5\\xd5\\xa5\\x02\\x00\\xb8\\x9f\\xe6\\x11M\"\\x00\\r\\xa2\\xcb\\x06\\x16\\x00\\rH\\x10a'", + } + }, "type": "ShellyBLEScanner", } }, diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index a5367408955..520233eaf60 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -6,7 +6,7 @@ from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.const import MODEL_I3 import pytest from pytest_unordered import unordered -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.event import ( ATTR_EVENT_TYPE, @@ -31,7 +31,7 @@ async def test_rpc_button( ) -> None: """Test RPC device event.""" await init_integration(hass, 2) - entity_id = "event.test_name_input_0" + entity_id = "event.test_name_test_input_0" assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN @@ -176,6 +176,7 @@ async def test_block_event( ) -> None: """Test block device event.""" await init_integration(hass, 1) + # num_outputs is 2, device name and channel name is used entity_id = "event.test_name_channel_1" assert (state := hass.states.get(entity_id)) @@ -201,11 +202,12 @@ async def test_block_event( async def test_block_event_shix3_1( - hass: HomeAssistant, mock_block_device: Mock + hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test block device event for SHIX3-1.""" + monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) await init_integration(hass, 1, model=MODEL_I3) - entity_id = "event.test_name_channel_1" + entity_id = "event.test_name" assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 4cf49a2dab8..703df09bb61 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, call, patch from aioshelly.block_device import COAP from aioshelly.common import ConnectionOptions -from aioshelly.const import MODEL_PLUS_2PM +from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_PLUS_2PM from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -38,6 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceRegistry, format_mac +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from . import MOCK_MAC, init_integration, mutate_rpc_device_status @@ -346,7 +347,7 @@ async def test_sleeping_rpc_device_offline_during_setup( ("gen", "entity_id"), [ (1, "switch.test_name_channel_1"), - (2, "switch.test_switch_0"), + (2, "switch.test_name_test_switch_0"), ], ) async def test_entry_unload( @@ -378,7 +379,7 @@ async def test_entry_unload( ("gen", "entity_id"), [ (1, "switch.test_name_channel_1"), - (2, "switch.test_switch_0"), + (2, "switch.test_name_test_switch_0"), ], ) async def test_entry_unload_device_not_ready( @@ -417,7 +418,7 @@ async def test_entry_unload_not_connected( ) assert entry.state is ConfigEntryState.LOADED - assert (state := hass.states.get("switch.test_switch_0")) + assert (state := hass.states.get("switch.test_name_test_switch_0")) assert state.state == STATE_ON assert not mock_stop_scanner.call_count @@ -448,7 +449,7 @@ async def test_entry_unload_not_connected_but_we_think_we_are( ) assert entry.state is ConfigEntryState.LOADED - assert (state := hass.states.get("switch.test_switch_0")) + assert (state := hass.states.get("switch.test_name_test_switch_0")) assert state.state == STATE_ON assert not mock_stop_scanner.call_count @@ -483,6 +484,7 @@ async def test_entry_missing_gen(hass: HomeAssistant, mock_block_device: Mock) - assert entry.state is ConfigEntryState.LOADED + # num_outputs is 2, channel name is used assert (state := hass.states.get("switch.test_name_channel_1")) assert state.state == STATE_ON @@ -605,3 +607,49 @@ async def test_ble_scanner_unsupported_firmware_fixed( assert not issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 0 + + +async def test_blu_trv_stale_device_removal( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV removal of stale a device after un-pairing.""" + trv_200_entity_id = "climate.trv_name" + trv_201_entity_id = "climate.trv_201" + + monkeypatch.setattr(mock_blu_trv, "model", MODEL_BLU_GATEWAY_G3) + gw_entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + # verify that both trv devices are present + assert hass.states.get(trv_200_entity_id) is not None + trv_200_entry = entity_registry.async_get(trv_200_entity_id) + assert trv_200_entry + + trv_200_device_entry = device_registry.async_get(trv_200_entry.device_id) + assert trv_200_device_entry + assert trv_200_device_entry.name == "TRV-Name" + + assert hass.states.get(trv_201_entity_id) is not None + trv_201_entry = entity_registry.async_get(trv_201_entity_id) + assert trv_201_entry + + trv_201_device_entry = device_registry.async_get(trv_201_entry.device_id) + assert trv_201_device_entry + assert trv_201_device_entry.name == "TRV-201" + + # simulate un-pairing of trv 201 device + monkeypatch.delitem(mock_blu_trv.config, "blutrv:201") + monkeypatch.delitem(mock_blu_trv.status, "blutrv:201") + + await hass.config_entries.async_reload(gw_entry.entry_id) + await hass.async_block_till_done() + + # verify that trv 201 is removed + assert hass.states.get(trv_200_entity_id) is not None + assert device_registry.async_get(trv_200_entry.device_id) is not None + + assert hass.states.get(trv_201_entity_id) is None + assert device_registry.async_get(trv_201_entry.device_id) is None diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 0dab06f53a9..9c79cf5d988 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -58,10 +58,14 @@ SHELLY_PLUS_RGBW_CHANNELS = 4 async def test_block_device_rgbw_bulb( - hass: HomeAssistant, mock_block_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_block_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device RGBW bulb.""" - entity_id = "light.test_name_channel_1" + monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) + entity_id = "light.test_name" await init_integration(hass, 1, model=MODEL_BULB) # Test initial @@ -142,7 +146,8 @@ async def test_block_device_rgb_bulb( caplog: pytest.LogCaptureFixture, ) -> None: """Test block device RGB bulb.""" - entity_id = "light.test_name_channel_1" + monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) + entity_id = "light.test_name" monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "mode") monkeypatch.setattr( mock_block_device.blocks[LIGHT_BLOCK_ID], "description", "light_1" @@ -246,7 +251,8 @@ async def test_block_device_white_bulb( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device white bulb.""" - entity_id = "light.test_name_channel_1" + monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) + entity_id = "light.test_name" monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "red") monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "green") monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "blue") @@ -322,6 +328,7 @@ async def test_block_device_support_transition( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device supports transition.""" + # num_outputs is 2, device name and channel name is used entity_id = "light.test_name_channel_1" monkeypatch.setitem( mock_block_device.settings, "fw", "20220809-122808/v1.12-g99f7e0b" @@ -448,7 +455,7 @@ async def test_rpc_device_switch_type_lights_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device with switch in consumption type lights mode.""" - entity_id = "light.test_switch_0" + entity_id = "light.test_name_test_switch_0" monkeypatch.setitem( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) @@ -595,7 +602,7 @@ async def test_rpc_device_rgb_profile( for i in range(SHELLY_PLUS_RGBW_CHANNELS): monkeypatch.delitem(mock_rpc_device.status, f"light:{i}") monkeypatch.delitem(mock_rpc_device.status, "rgbw:0") - entity_id = "light.test_rgb_0" + entity_id = "light.test_name_test_rgb_0" await init_integration(hass, 2) # Test initial @@ -639,7 +646,7 @@ async def test_rpc_device_rgbw_profile( for i in range(SHELLY_PLUS_RGBW_CHANNELS): monkeypatch.delitem(mock_rpc_device.status, f"light:{i}") monkeypatch.delitem(mock_rpc_device.status, "rgb:0") - entity_id = "light.test_rgbw_0" + entity_id = "light.test_name_test_rgbw_0" await init_integration(hass, 2) # Test initial @@ -753,7 +760,7 @@ async def test_rpc_rgbw_device_rgb_w_modes_remove_others( # register lights for i in range(SHELLY_PLUS_RGBW_CHANNELS): monkeypatch.delitem(mock_rpc_device.status, f"light:{i}") - entity_id = f"light.test_light_{i}" + entity_id = f"light.test_name_test_light_{i}" register_entity( hass, LIGHT_DOMAIN, @@ -781,7 +788,7 @@ async def test_rpc_rgbw_device_rgb_w_modes_remove_others( await hass.async_block_till_done() # verify we have RGB/w light - entity_id = f"light.test_{active_mode}_0" + entity_id = f"light.test_name_test_{active_mode}_0" assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON diff --git a/tests/components/shelly/test_logbook.py b/tests/components/shelly/test_logbook.py index 8962b26544b..08256e03f4e 100644 --- a/tests/components/shelly/test_logbook.py +++ b/tests/components/shelly/test_logbook.py @@ -108,7 +108,7 @@ async def test_humanify_shelly_click_event_rpc_device( assert event1["domain"] == DOMAIN assert ( event1["message"] - == "'single_push' click event for Test name input 0 Input was fired" + == "'single_push' click event for Test name Test input 0 Input was fired" ) assert event2["name"] == "Shelly" diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 8589d643b2b..e33b04721cc 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock from aioshelly.const import MODEL_BLU_GATEWAY_G3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_MAX, diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 7edd38a4b31..e95d4cfaeb2 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import Mock from aioshelly.const import MODEL_BLU_GATEWAY_G3 from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, @@ -62,6 +62,7 @@ async def test_block_sensor( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block sensor.""" + # num_outputs is 2, channel name is used entity_id = f"{SENSOR_DOMAIN}.test_name_channel_1_power" await init_integration(hass, 1) @@ -82,6 +83,7 @@ async def test_energy_sensor( hass: HomeAssistant, mock_block_device: Mock, entity_registry: EntityRegistry ) -> None: """Test energy sensor.""" + # num_outputs is 2, channel name is used entity_id = f"{SENSOR_DOMAIN}.test_name_channel_1_energy" await init_integration(hass, 1) @@ -430,7 +432,9 @@ async def test_block_shelly_air_lamp_life( percentage: float, ) -> None: """Test block Shelly Air lamp life percentage sensor.""" - entity_id = f"{SENSOR_DOMAIN}.{'test_name_channel_1_lamp_life'}" + monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) + # num_outputs is 1, device name is used + entity_id = f"{SENSOR_DOMAIN}.{'test_name_lamp_life'}" monkeypatch.setattr( mock_block_device.blocks[RELAY_BLOCK_ID], "totalWorkTime", lamp_life_seconds ) @@ -444,7 +448,7 @@ async def test_rpc_sensor( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test RPC sensor.""" - entity_id = f"{SENSOR_DOMAIN}.test_cover_0_power" + entity_id = f"{SENSOR_DOMAIN}.test_name_test_cover_0_power" await init_integration(hass, 2) assert (state := hass.states.get(entity_id)) @@ -673,37 +677,45 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_rpc_em1_sensors( +async def test_rpc_energy_meter_1_sensors( hass: HomeAssistant, entity_registry: EntityRegistry, mock_rpc_device: Mock ) -> None: """Test RPC sensors for EM1 component.""" await init_integration(hass, 2) - assert (state := hass.states.get("sensor.test_name_em0_power")) + assert (state := hass.states.get("sensor.test_name_energy_meter_0_power")) assert state.state == "85.3" - assert (entry := entity_registry.async_get("sensor.test_name_em0_power")) + assert (entry := entity_registry.async_get("sensor.test_name_energy_meter_0_power")) assert entry.unique_id == "123456789ABC-em1:0-power_em1" - assert (state := hass.states.get("sensor.test_name_em1_power")) + assert (state := hass.states.get("sensor.test_name_energy_meter_1_power")) assert state.state == "123.3" - assert (entry := entity_registry.async_get("sensor.test_name_em1_power")) + assert (entry := entity_registry.async_get("sensor.test_name_energy_meter_1_power")) assert entry.unique_id == "123456789ABC-em1:1-power_em1" - assert (state := hass.states.get("sensor.test_name_em0_total_active_energy")) + assert ( + state := hass.states.get("sensor.test_name_energy_meter_0_total_active_energy") + ) assert state.state == "123.4564" assert ( - entry := entity_registry.async_get("sensor.test_name_em0_total_active_energy") + entry := entity_registry.async_get( + "sensor.test_name_energy_meter_0_total_active_energy" + ) ) assert entry.unique_id == "123456789ABC-em1data:0-total_act_energy" - assert (state := hass.states.get("sensor.test_name_em1_total_active_energy")) + assert ( + state := hass.states.get("sensor.test_name_energy_meter_1_total_active_energy") + ) assert state.state == "987.6543" assert ( - entry := entity_registry.async_get("sensor.test_name_em1_total_active_energy") + entry := entity_registry.async_get( + "sensor.test_name_energy_meter_1_total_active_energy" + ) ) assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" @@ -901,7 +913,7 @@ async def test_rpc_pulse_counter_sensors( await init_integration(hass, 2) - entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter" + entity_id = f"{SENSOR_DOMAIN}.test_name_gas_pulse_counter" assert (state := hass.states.get(entity_id)) assert state.state == "56174" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "pulse" @@ -910,7 +922,7 @@ async def test_rpc_pulse_counter_sensors( assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-pulse_counter" - entity_id = f"{SENSOR_DOMAIN}.gas_counter_value" + entity_id = f"{SENSOR_DOMAIN}.test_name_gas_counter_value" assert (state := hass.states.get(entity_id)) assert state.state == "561.74" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit @@ -949,11 +961,11 @@ async def test_rpc_disabled_xtotal_counter( ) await init_integration(hass, 2) - entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter" + entity_id = f"{SENSOR_DOMAIN}.test_name_gas_pulse_counter" assert (state := hass.states.get(entity_id)) assert state.state == "20635" - entity_id = f"{SENSOR_DOMAIN}.gas_counter_value" + entity_id = f"{SENSOR_DOMAIN}.test_name_gas_counter_value" assert hass.states.get(entity_id) is None @@ -980,7 +992,7 @@ async def test_rpc_pulse_counter_frequency_sensors( await init_integration(hass, 2) - entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency" + entity_id = f"{SENSOR_DOMAIN}.test_name_gas_pulse_counter_frequency" assert (state := hass.states.get(entity_id)) assert state.state == "208.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfFrequency.HERTZ @@ -989,7 +1001,7 @@ async def test_rpc_pulse_counter_frequency_sensors( assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_frequency" - entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency_value" + entity_id = f"{SENSOR_DOMAIN}.test_name_gas_pulse_counter_frequency_value" assert (state := hass.states.get(entity_id)) assert state.state == "6.11" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit @@ -1411,7 +1423,7 @@ async def test_rpc_rgbw_sensors( assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-voltage_{light_type}" - entity_id = f"sensor.test_name_{light_type}_light_0_device_temperature" + entity_id = f"sensor.test_name_{light_type}_light_0_temperature" assert (state := hass.states.get(entity_id)) assert state.state == "54.3" @@ -1519,3 +1531,57 @@ async def test_rpc_device_virtual_number_sensor_with_device_class( assert state.state == "34" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_switch_energy_sensors( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test energy sensors for switch component.""" + status = { + "sys": {}, + "switch:0": { + "id": 0, + "output": True, + "apower": 85.3, + "aenergy": {"total": 1234567.89}, + "ret_aenergy": {"total": 98765.43}, + }, + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + for entity in ("energy", "returned_energy"): + entity_id = f"{SENSOR_DOMAIN}.test_name_test_switch_0_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_switch_no_returned_energy_sensor( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test switch component without returned energy sensor.""" + status = { + "sys": {}, + "switch:0": { + "id": 0, + "output": True, + "apower": 85.3, + "aenergy": {"total": 1234567.89}, + }, + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + assert hass.states.get("sensor.test_name_test_switch_0_returned_energy") is None diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 824742d1798..54923b538f6 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -42,6 +42,7 @@ async def test_block_device_services( ) -> None: """Test block device turn on/off services.""" await init_integration(hass, 1) + # num_outputs is 2, device_name and channel name is used entity_id = "switch.test_name_channel_1" await hass.services.async_call( @@ -192,7 +193,7 @@ async def test_block_restored_motion_switch_no_last_state( @pytest.mark.parametrize( ("model", "sleep", "entity", "unique_id"), [ - (MODEL_1PM, 0, "switch.test_name_channel_1", "123456789ABC-relay_0"), + (MODEL_1PM, 0, "switch.test_name", "123456789ABC-relay_0"), ( MODEL_MOTION, 1000, @@ -205,12 +206,15 @@ async def test_block_device_unique_ids( hass: HomeAssistant, entity_registry: EntityRegistry, mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, model: str, sleep: int, entity: str, unique_id: str, ) -> None: """Test block device unique_ids.""" + monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) + # num_outputs is 1, device name is used await init_integration(hass, 1, model=model, sleep_period=sleep) if sleep: @@ -332,7 +336,7 @@ async def test_rpc_device_services( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - entity_id = "switch.test_switch_0" + entity_id = "switch.test_name_test_switch_0" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -365,7 +369,7 @@ async def test_rpc_device_unique_ids( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - assert (entry := entity_registry.async_get("switch.test_switch_0")) + assert (entry := entity_registry.async_get("switch.test_name_test_switch_0")) assert entry.unique_id == "123456789ABC-switch:0" @@ -386,11 +390,11 @@ async def test_rpc_device_switch_type_lights_mode( [ ( DeviceConnectionError, - "Device communication error occurred while calling action for switch.test_switch_0 of Test name", + "Device communication error occurred while calling action for switch.test_name_test_switch_0 of Test name", ), ( RpcCallError(-1, "error"), - "RPC call error occurred while calling action for switch.test_switch_0 of Test name", + "RPC call error occurred while calling action for switch.test_name_test_switch_0 of Test name", ), ], ) @@ -411,7 +415,7 @@ async def test_rpc_set_state_errors( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_switch_0"}, + {ATTR_ENTITY_ID: "switch.test_name_test_switch_0"}, blocking=True, ) @@ -434,7 +438,7 @@ async def test_rpc_auth_error( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_switch_0"}, + {ATTR_ENTITY_ID: "switch.test_name_test_switch_0"}, blocking=True, ) @@ -476,8 +480,8 @@ async def test_wall_display_relay_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test Wall Display in relay mode.""" - climate_entity_id = "climate.test_name_thermostat_0" - switch_entity_id = "switch.test_switch_0" + climate_entity_id = "climate.test_name" + switch_entity_id = "switch.test_name_test_switch_0" config_entry = await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index ae3caa93825..0cdd1640e65 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -79,37 +79,38 @@ async def test_block_get_block_channel_name( mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test block get block channel name.""" - monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "type", "relay") - - assert ( - get_block_channel_name( - mock_block_device, - mock_block_device.blocks[DEVICE_BLOCK_ID], - ) - == "Test name channel 1" + result = get_block_channel_name( + mock_block_device, + mock_block_device.blocks[DEVICE_BLOCK_ID], ) + # when has_entity_name is True the result should be None + assert result is None + + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "type", "relay") + result = get_block_channel_name( + mock_block_device, + mock_block_device.blocks[DEVICE_BLOCK_ID], + ) + # when has_entity_name is True the result should be None + assert result is None monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_EM3) - - assert ( - get_block_channel_name( - mock_block_device, - mock_block_device.blocks[DEVICE_BLOCK_ID], - ) - == "Test name channel A" + result = get_block_channel_name( + mock_block_device, + mock_block_device.blocks[DEVICE_BLOCK_ID], ) + # when has_entity_name is True the result should be None + assert result is None monkeypatch.setitem( mock_block_device.settings, "relays", [{"name": "test-channel"}] ) - - assert ( - get_block_channel_name( - mock_block_device, - mock_block_device.blocks[DEVICE_BLOCK_ID], - ) - == "test-channel" + result = get_block_channel_name( + mock_block_device, + mock_block_device.blocks[DEVICE_BLOCK_ID], ) + # when has_entity_name is True the result should be None + assert result is None async def test_is_block_momentary_input( @@ -241,20 +242,19 @@ async def test_get_block_input_triggers( async def test_get_rpc_channel_name(mock_rpc_device: Mock) -> None: """Test get RPC channel name.""" - assert get_rpc_channel_name(mock_rpc_device, "input:0") == "Test name input 0" - assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name Input 3" + assert get_rpc_channel_name(mock_rpc_device, "input:0") == "Test input 0" + assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Input 3" @pytest.mark.parametrize( ("component", "expected"), [ - ("cover", "Cover"), - ("input", "Input"), - ("light", "Light"), - ("rgb", "RGB light"), - ("rgbw", "RGBW light"), - ("switch", "Switch"), - ("thermostat", "Thermostat"), + ("cover", None), + ("light", None), + ("rgb", None), + ("rgbw", None), + ("switch", None), + ("thermostat", None), ], ) async def test_get_rpc_channel_name_multiple_components( @@ -270,14 +270,9 @@ async def test_get_rpc_channel_name_multiple_components( } monkeypatch.setattr(mock_rpc_device, "config", config) - assert ( - get_rpc_channel_name(mock_rpc_device, f"{component}:0") - == f"Test name {expected} 0" - ) - assert ( - get_rpc_channel_name(mock_rpc_device, f"{component}:1") - == f"Test name {expected} 1" - ) + # we use sub-devices, so the entity name is not set + assert get_rpc_channel_name(mock_rpc_device, f"{component}:0") == expected + assert get_rpc_channel_name(mock_rpc_device, f"{component}:1") == expected async def test_get_rpc_input_triggers( diff --git a/tests/components/simplefin/snapshots/test_binary_sensor.ambr b/tests/components/simplefin/snapshots/test_binary_sensor.ambr index 3123100205e..6602e6e35a9 100644 --- a/tests/components/simplefin/snapshots/test_binary_sensor.ambr +++ b/tests/components/simplefin/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Possible error', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'possible_error', 'unique_id': 'account_ACT-4k5l6m7n-8o9p-1q2r-3s4t_possible_error', @@ -76,6 +77,7 @@ 'original_name': 'Possible error', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'possible_error', 'unique_id': 'account_ACT-1k2l3m4n-5o6p-7q8r-9s0t_possible_error', @@ -125,6 +127,7 @@ 'original_name': 'Possible error', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'possible_error', 'unique_id': 'account_ACT-5k6l7m8n-9o0p-1q2r-3s4t_possible_error', @@ -174,6 +177,7 @@ 'original_name': 'Possible error', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'possible_error', 'unique_id': 'account_ACT-7a8b9c0d-1e2f-3g4h-5i6j_possible_error', @@ -223,6 +227,7 @@ 'original_name': 'Possible error', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'possible_error', 'unique_id': 'account_ACT-6a7b8c9d-0e1f-2g3h-4i5j_possible_error', @@ -272,6 +277,7 @@ 'original_name': 'Possible error', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'possible_error', 'unique_id': 'account_ACT-3a4b5c6d-7e8f-9g0h-1i2j_possible_error', @@ -321,6 +327,7 @@ 'original_name': 'Possible error', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'possible_error', 'unique_id': 'account_ACT-2a3b4c5d-6e7f-8g9h-0i1j_possible_error', @@ -370,6 +377,7 @@ 'original_name': 'Possible error', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'possible_error', 'unique_id': 'account_ACT-1a2b3c4d-5e6f-7g8h-9i0j_possible_error', diff --git a/tests/components/simplefin/snapshots/test_sensor.ambr b/tests/components/simplefin/snapshots/test_sensor.ambr index dd305f7528f..7f3e8d342fb 100644 --- a/tests/components/simplefin/snapshots/test_sensor.ambr +++ b/tests/components/simplefin/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Balance', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'account_ACT-4k5l6m7n-8o9p-1q2r-3s4t_balance', @@ -81,6 +82,7 @@ 'original_name': 'Data age', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': 'account_ACT-4k5l6m7n-8o9p-1q2r-3s4t_age', @@ -132,6 +134,7 @@ 'original_name': 'Balance', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'account_ACT-1k2l3m4n-5o6p-7q8r-9s0t_balance', @@ -184,6 +187,7 @@ 'original_name': 'Data age', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': 'account_ACT-1k2l3m4n-5o6p-7q8r-9s0t_age', @@ -235,6 +239,7 @@ 'original_name': 'Balance', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'account_ACT-5k6l7m8n-9o0p-1q2r-3s4t_balance', @@ -287,6 +292,7 @@ 'original_name': 'Data age', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': 'account_ACT-5k6l7m8n-9o0p-1q2r-3s4t_age', @@ -338,6 +344,7 @@ 'original_name': 'Balance', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'account_ACT-7a8b9c0d-1e2f-3g4h-5i6j_balance', @@ -390,6 +397,7 @@ 'original_name': 'Data age', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': 'account_ACT-7a8b9c0d-1e2f-3g4h-5i6j_age', @@ -441,6 +449,7 @@ 'original_name': 'Balance', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'account_ACT-6a7b8c9d-0e1f-2g3h-4i5j_balance', @@ -493,6 +502,7 @@ 'original_name': 'Data age', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': 'account_ACT-6a7b8c9d-0e1f-2g3h-4i5j_age', @@ -544,6 +554,7 @@ 'original_name': 'Balance', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'account_ACT-3a4b5c6d-7e8f-9g0h-1i2j_balance', @@ -596,6 +607,7 @@ 'original_name': 'Data age', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': 'account_ACT-3a4b5c6d-7e8f-9g0h-1i2j_age', @@ -647,6 +659,7 @@ 'original_name': 'Balance', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'account_ACT-2a3b4c5d-6e7f-8g9h-0i1j_balance', @@ -699,6 +712,7 @@ 'original_name': 'Data age', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': 'account_ACT-2a3b4c5d-6e7f-8g9h-0i1j_age', @@ -750,6 +764,7 @@ 'original_name': 'Balance', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'balance', 'unique_id': 'account_ACT-1a2b3c4d-5e6f-7g8h-9i0j_balance', @@ -802,6 +817,7 @@ 'original_name': 'Data age', 'platform': 'simplefin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'age', 'unique_id': 'account_ACT-1a2b3c4d-5e6f-7g8h-9i0j_age', diff --git a/tests/components/simplefin/test_binary_sensor.py b/tests/components/simplefin/test_binary_sensor.py index 40c6882153d..58b0319d71f 100644 --- a/tests/components/simplefin/test_binary_sensor.py +++ b/tests/components/simplefin/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/simplefin/test_sensor.py b/tests/components/simplefin/test_sensor.py index 495f249d4e1..b26cd620a69 100644 --- a/tests/components/simplefin/test_sensor.py +++ b/tests/components/simplefin/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from simplefin4py.exceptions import SimpleFinAuthError, SimpleFinPaymentRequiredError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/slide_local/snapshots/test_button.ambr b/tests/components/slide_local/snapshots/test_button.ambr index 7b363f4d9ba..9ab1ff9623d 100644 --- a/tests/components/slide_local/snapshots/test_button.ambr +++ b/tests/components/slide_local/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Calibrate', 'platform': 'slide_local', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calibrate', 'unique_id': '1234567890ab-calibrate', diff --git a/tests/components/slide_local/snapshots/test_cover.ambr b/tests/components/slide_local/snapshots/test_cover.ambr index 172f5411a94..09d182a4bb6 100644 --- a/tests/components/slide_local/snapshots/test_cover.ambr +++ b/tests/components/slide_local/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'slide_local', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1234567890ab', diff --git a/tests/components/slide_local/snapshots/test_switch.ambr b/tests/components/slide_local/snapshots/test_switch.ambr index 9b1a7969539..ddfe7151f44 100644 --- a/tests/components/slide_local/snapshots/test_switch.ambr +++ b/tests/components/slide_local/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'TouchGo', 'platform': 'slide_local', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'touchgo', 'unique_id': '1234567890ab-touchgo', diff --git a/tests/components/slide_local/test_button.py b/tests/components/slide_local/test_button.py index c232affbb99..d4bf955ad58 100644 --- a/tests/components/slide_local/test_button.py +++ b/tests/components/slide_local/test_button.py @@ -9,7 +9,7 @@ from goslideapi.goslideapi import ( DigestAuthCalcError, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/slide_local/test_cover.py b/tests/components/slide_local/test_cover.py index e0e4a0741d8..793f9d9513d 100644 --- a/tests/components/slide_local/test_cover.py +++ b/tests/components/slide_local/test_cover.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from goslideapi.goslideapi import ClientConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_POSITION, diff --git a/tests/components/slide_local/test_diagnostics.py b/tests/components/slide_local/test_diagnostics.py index 3e11af378c5..cebc4443882 100644 --- a/tests/components/slide_local/test_diagnostics.py +++ b/tests/components/slide_local/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.const import Platform diff --git a/tests/components/slide_local/test_init.py b/tests/components/slide_local/test_init.py index ec9a12f9eeb..27aba115cf8 100644 --- a/tests/components/slide_local/test_init.py +++ b/tests/components/slide_local/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from goslideapi.goslideapi import ClientConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform diff --git a/tests/components/slide_local/test_switch.py b/tests/components/slide_local/test_switch.py index 9d0d8274aa5..85f90974ce6 100644 --- a/tests/components/slide_local/test_switch.py +++ b/tests/components/slide_local/test_switch.py @@ -9,7 +9,7 @@ from goslideapi.goslideapi import ( DigestAuthCalcError, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 4a9e462501e..61d3f81a9fc 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -1,8 +1,5 @@ """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, @@ -11,12 +8,16 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, ) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry MOCK_DEVICE = { "manufacturer": "SMA", "name": "SMA Device Name", "type": "Sunny Boy 3.6", "serial": 123456789, + "sw_version": "1.0.0", } MOCK_USER_INPUT = { @@ -27,8 +28,11 @@ MOCK_USER_INPUT = { CONF_PASSWORD: "password", } +MOCK_USER_REAUTH = { + CONF_PASSWORD: "new_password", +} + MOCK_DHCP_DISCOVERY_INPUT = { - # CONF_HOST: "1.1.1.2", CONF_SSL: True, CONF_VERIFY_SSL: False, CONF_GROUP: "user", @@ -45,9 +49,9 @@ MOCK_DHCP_DISCOVERY = { } -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, - ) +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/sma/conftest.py b/tests/components/sma/conftest.py index 2b4c157175b..5b4ab23213c 100644 --- a/tests/components/sma/conftest.py +++ b/tests/components/sma/conftest.py @@ -1,13 +1,17 @@ """Fixtures for sma tests.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch -from pysma.const import GENERIC_SENSORS +from pysma.const import ( + ENERGY_METER_VIA_INVERTER, + GENERIC_SENSORS, + OPTIMIZERS_VIA_INVERTER, +) from pysma.definitions import sensor_map from pysma.sensor import Sensors import pytest -from homeassistant import config_entries from homeassistant.components.sma.const import DOMAIN from homeassistant.core import HomeAssistant @@ -19,31 +23,54 @@ from tests.common import MockConfigEntry @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" - entry = MockConfigEntry( + + return MockConfigEntry( domain=DOMAIN, title=MOCK_DEVICE["name"], unique_id=str(MOCK_DEVICE["serial"]), data=MOCK_USER_INPUT, - source=config_entries.SOURCE_IMPORT, minor_version=2, + entry_id="sma_entry_123", ) - entry.add_to_hass(hass) - return entry @pytest.fixture -async def init_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> MockConfigEntry: - """Create a fake SMA Config Entry.""" - mock_config_entry.add_to_hass(hass) +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry.""" + with patch( + "homeassistant.components.sma.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry - with ( - patch("pysma.SMA.read"), - patch( - "pysma.SMA.get_sensors", return_value=Sensors(sensor_map[GENERIC_SENSORS]) - ), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - return mock_config_entry + +@pytest.fixture +def mock_sma_client() -> Generator[MagicMock]: + """Mock the SMA client.""" + with patch("homeassistant.components.sma.pysma.SMA", autospec=True) as client: + client.return_value.device_info.return_value = MOCK_DEVICE + client.new_session.return_value = True + client.return_value.get_sensors.return_value = Sensors( + sensor_map[GENERIC_SENSORS] + + sensor_map[OPTIMIZERS_VIA_INVERTER] + + sensor_map[ENERGY_METER_VIA_INVERTER] + ) + + default_sensor_values = { + "6100_00499100": 5000, + "6100_00499500": 230, + "6100_00499200": 20, + "6100_00499300": 50, + "6100_00499400": 100, + "6100_00499600": 10, + "6100_00499700": 1000, + } + + def mock_read(sensors): + for sensor in sensors: + if sensor.key in default_sensor_values: + sensor.value = default_sensor_values[sensor.key] + return True + + client.return_value.read.side_effect = mock_read + + yield client diff --git a/tests/components/sma/snapshots/test_diagnostics.ambr b/tests/components/sma/snapshots/test_diagnostics.ambr index 14b0d120190..e8a119291d4 100644 --- a/tests/components/sma/snapshots/test_diagnostics.ambr +++ b/tests/components/sma/snapshots/test_diagnostics.ambr @@ -20,7 +20,7 @@ }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, - 'source': 'import', + 'source': 'user', 'subentries': list([ ]), 'title': 'SMA Device Name', diff --git a/tests/components/sma/snapshots/test_sensor.ambr b/tests/components/sma/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..257f07d1a32 --- /dev/null +++ b/tests/components/sma/snapshots/test_sensor.ambr @@ -0,0 +1,5929 @@ +# serializer version: 1 +# name: test_all_entities[sensor.sma_device_name_battery_capacity_a-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.sma_device_name_battery_capacity_a', + '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': 'SMA Device Name Battery Capacity A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499100_0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Capacity A', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_capacity_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_b-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.sma_device_name_battery_capacity_b', + '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': 'SMA Device Name Battery Capacity B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499100_1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Capacity B', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_capacity_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_c-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.sma_device_name_battery_capacity_c', + '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': 'SMA Device Name Battery Capacity C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499100_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Capacity C', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_capacity_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_total-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.sma_device_name_battery_capacity_total', + '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': 'SMA Device Name Battery Capacity Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00696E00_0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Capacity Total', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_capacity_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_a-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.sma_device_name_battery_charge_a', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Charge A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Charge A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charge_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_b-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.sma_device_name_battery_charge_b', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Charge B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499500_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Charge B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charge_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_c-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.sma_device_name_battery_charge_c', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Charge C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499500_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Charge C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charge_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_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': None, + 'entity_id': 'sensor.sma_device_name_battery_charge_total', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Charge Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00496700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Charge Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_a-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.sma_device_name_battery_charging_voltage_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charging Voltage A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_00493500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Charging Voltage A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charging_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_b-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.sma_device_name_battery_charging_voltage_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charging Voltage B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_00493500_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Charging Voltage B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charging_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_c-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.sma_device_name_battery_charging_voltage_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charging Voltage C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_00493500_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Charging Voltage C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charging_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_a-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.sma_device_name_battery_current_a', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Current A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495D00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Battery Current A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_current_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_b-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.sma_device_name_battery_current_b', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Current B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495D00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Battery Current B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_current_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_c-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.sma_device_name_battery_current_c', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Current C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495D00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Battery Current C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_current_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_a-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.sma_device_name_battery_discharge_a', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Discharge A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Discharge A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_discharge_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_b-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.sma_device_name_battery_discharge_b', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Discharge B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499600_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Discharge B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_discharge_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_c-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.sma_device_name_battery_discharge_c', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Discharge C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499600_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Discharge C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_discharge_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_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': None, + 'entity_id': 'sensor.sma_device_name_battery_discharge_total', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Discharge Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00496800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Discharge Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_discharge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_a-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.sma_device_name_battery_power_charge_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Charge A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499300_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Charge A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_charge_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_b-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.sma_device_name_battery_power_charge_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Charge B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499300_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Charge B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_charge_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_c-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.sma_device_name_battery_power_charge_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Charge C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499300_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Charge C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_charge_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_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': None, + 'entity_id': 'sensor.sma_device_name_battery_power_charge_total', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Charge Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00496900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Charge Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_charge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_a-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.sma_device_name_battery_power_discharge_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Discharge A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499400_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Discharge A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_b-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.sma_device_name_battery_power_discharge_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Discharge B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499400_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Discharge B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_c-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.sma_device_name_battery_power_discharge_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Discharge C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499400_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Discharge C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_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': None, + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_total', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Discharge Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00496A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Discharge Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_a-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.sma_device_name_battery_soc_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery SOC A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00498F00_0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SMA Device Name Battery SOC A', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_soc_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_b-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.sma_device_name_battery_soc_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery SOC B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00498F00_1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SMA Device Name Battery SOC B', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_soc_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_c-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.sma_device_name_battery_soc_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery SOC C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00498F00_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SMA Device Name Battery SOC C', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_soc_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_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': None, + 'entity_id': 'sensor.sma_device_name_battery_soc_total', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery SOC Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00295A00_0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SMA Device Name Battery SOC Total', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_soc_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_status_operating_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_status_operating_mode', + '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': 'SMA Device Name Battery Status Operating Mode', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08495E00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_status_operating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Status Operating Mode', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_status_operating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_a-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.sma_device_name_battery_temp_a', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Temp A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495B00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SMA Device Name Battery Temp A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_temp_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_b-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.sma_device_name_battery_temp_b', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Temp B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495B00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SMA Device Name Battery Temp B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_temp_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_c-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.sma_device_name_battery_temp_c', + 'has_entity_name': False, + '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': 'SMA Device Name Battery Temp C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495B00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SMA Device Name Battery Temp C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_temp_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_a-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.sma_device_name_battery_voltage_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Voltage A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00495C00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Voltage A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_b-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.sma_device_name_battery_voltage_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Voltage B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00495C00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Voltage B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_c-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.sma_device_name_battery_voltage_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Voltage C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00495C00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Voltage C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': None, + 'entity_id': 'sensor.sma_device_name_current_l1', + 'has_entity_name': False, + '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': 'SMA Device Name Current L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40465300_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': None, + 'entity_id': 'sensor.sma_device_name_current_l2', + 'has_entity_name': False, + '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': 'SMA Device Name Current L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40465400_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': None, + 'entity_id': 'sensor.sma_device_name_current_l3', + 'has_entity_name': False, + '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': 'SMA Device Name Current L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40465500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_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': None, + 'entity_id': 'sensor.sma_device_name_current_total', + 'has_entity_name': False, + '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': 'SMA Device Name Current Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00664F00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Current Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_current_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_daily_yield-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.sma_device_name_daily_yield', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Daily Yield', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00262200_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_daily_yield-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Daily Yield', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_daily_yield', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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.sma_device_name_frequency', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Frequency', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00465700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'SMA Device Name Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_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.sma_device_name_grid_apparent_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Apparent Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'SMA Device Name Grid Apparent Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_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': None, + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Apparent Power L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'SMA Device Name Grid Apparent Power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_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': None, + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Apparent Power L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'SMA Device Name Grid Apparent Power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_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': None, + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Apparent Power L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'SMA Device Name Grid Apparent Power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_connection_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': , + 'entity_id': 'sensor.sma_device_name_grid_connection_status', + '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': 'SMA Device Name Grid Connection Status', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_0846A700_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Grid Connection Status', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_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.sma_device_name_grid_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40263F00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Grid Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power_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': None, + 'entity_id': 'sensor.sma_device_name_grid_power_factor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Power Factor', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00665900_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'SMA Device Name Grid Power Factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power_factor_excitation-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.sma_device_name_grid_power_factor_excitation', + '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': 'SMA Device Name Grid Power Factor Excitation', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08465A00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power_factor_excitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Grid Power Factor Excitation', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_power_factor_excitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_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.sma_device_name_grid_reactive_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Reactive Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40265F00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'SMA Device Name Grid Reactive Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_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': None, + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Reactive Power L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666000_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'SMA Device Name Grid Reactive Power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_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': None, + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Reactive Power L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'SMA Device Name Grid Reactive Power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_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': None, + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Reactive Power L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666200_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'SMA Device Name Grid Reactive Power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_relay_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': , + 'entity_id': 'sensor.sma_device_name_grid_relay_status', + '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': 'SMA Device Name Grid Relay Status', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08416400_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_relay_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Grid Relay Status', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_relay_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_insulation_residual_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.sma_device_name_insulation_residual_current', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Insulation Residual Current', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_40254E00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_insulation_residual_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Insulation Residual Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_insulation_residual_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_condition-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.sma_device_name_inverter_condition', + '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': 'SMA Device Name Inverter Condition', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08414C00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Inverter Condition', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_inverter_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_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': None, + 'entity_id': 'sensor.sma_device_name_inverter_power_limit', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Inverter Power Limit', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6800_00832A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Inverter Power Limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_inverter_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_system_init-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.sma_device_name_inverter_system_init', + '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': 'SMA Device Name Inverter System Init', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6800_08811F00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_system_init-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Inverter System Init', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_inverter_system_init', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_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': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Draw L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046EB00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Draw L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_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': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Draw L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046EC00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Draw L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_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': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Draw L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046ED00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Draw L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_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': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Feed L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Feed L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_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': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Feed L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Feed L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_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': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Feed L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046EA00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Feed L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_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.sma_device_name_metering_current_consumption', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Current Consumption', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00543100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Current Consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_current_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_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': None, + 'entity_id': 'sensor.sma_device_name_metering_current_l1', + 'has_entity_name': False, + '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': 'SMA Device Name Metering Current L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40466500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Metering Current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_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': None, + 'entity_id': 'sensor.sma_device_name_metering_current_l2', + 'has_entity_name': False, + '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': 'SMA Device Name Metering Current L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40466600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Metering Current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_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': None, + 'entity_id': 'sensor.sma_device_name_metering_current_l3', + 'has_entity_name': False, + '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': 'SMA Device Name Metering Current L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40466B00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Metering Current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_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.sma_device_name_metering_frequency', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Frequency', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00468100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'SMA Device Name Metering Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_power_absorbed-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.sma_device_name_metering_power_absorbed', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Power Absorbed', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40463700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_power_absorbed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Power Absorbed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_power_absorbed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_power_supplied-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.sma_device_name_metering_power_supplied', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Power Supplied', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40463600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_power_supplied-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Power Supplied', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_power_supplied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_absorbed-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.sma_device_name_metering_total_absorbed', + 'has_entity_name': False, + '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': 'SMA Device Name Metering Total Absorbed', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00462500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_absorbed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Metering Total Absorbed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_total_absorbed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_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.sma_device_name_metering_total_consumption', + 'has_entity_name': False, + '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': 'SMA Device Name Metering Total Consumption', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00543A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Metering Total Consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_total_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_yield-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.sma_device_name_metering_total_yield', + 'has_entity_name': False, + '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': 'SMA Device Name Metering Total Yield', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00462400_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_yield-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Metering Total Yield', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_total_yield', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_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': None, + 'entity_id': 'sensor.sma_device_name_metering_voltage_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Voltage L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Metering Voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_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': None, + 'entity_id': 'sensor.sma_device_name_metering_voltage_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Voltage L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Metering Voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_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': None, + 'entity_id': 'sensor.sma_device_name_metering_voltage_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Voltage L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Metering Voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_operating_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': , + 'entity_id': 'sensor.sma_device_name_operating_status', + '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': 'SMA Device Name Operating Status', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08412B00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_operating_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Operating Status', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_operating_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_operating_status_general-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.sma_device_name_operating_status_general', + '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': 'SMA Device Name Operating Status General', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08412800_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_operating_status_general-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Operating Status General', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_operating_status_general', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_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.sma_device_name_optimizer_current', + 'has_entity_name': False, + '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': 'SMA Device Name Optimizer Current', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40652900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Optimizer Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_optimizer_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_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.sma_device_name_optimizer_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Optimizer Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40652A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Optimizer Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_optimizer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_temp-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.sma_device_name_optimizer_temp', + 'has_entity_name': False, + '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': 'SMA Device Name Optimizer Temp', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40652B00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_temp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SMA Device Name Optimizer Temp', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_optimizer_temp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_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.sma_device_name_optimizer_voltage', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Optimizer Voltage', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40652800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Optimizer Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_optimizer_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': None, + 'entity_id': 'sensor.sma_device_name_power_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Power L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40464000_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': None, + 'entity_id': 'sensor.sma_device_name_power_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Power L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40464100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': None, + 'entity_id': 'sensor.sma_device_name_power_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Power L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40464200_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_a-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.sma_device_name_pv_current_a', + 'has_entity_name': False, + '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': 'SMA Device Name PV Current A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40452100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name PV Current A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_current_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_b-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.sma_device_name_pv_current_b', + 'has_entity_name': False, + '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': 'SMA Device Name PV Current B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40452100_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name PV Current B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_current_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_c-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.sma_device_name_pv_current_c', + 'has_entity_name': False, + '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': 'SMA Device Name PV Current C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40452100_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name PV Current C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_current_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_gen_meter-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.sma_device_name_pv_gen_meter', + 'has_entity_name': False, + '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': 'SMA Device Name PV Gen Meter', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_0046C300_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_gen_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name PV Gen Meter', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_gen_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_isolation_resistance-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.sma_device_name_pv_isolation_resistance', + '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': 'SMA Device Name PV Isolation Resistance', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_00254F00_0', + 'unit_of_measurement': 'kOhms', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_isolation_resistance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name PV Isolation Resistance', + 'state_class': , + 'unit_of_measurement': 'kOhms', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_isolation_resistance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_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.sma_device_name_pv_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046C200_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name PV Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_a-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.sma_device_name_pv_power_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Power A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40251E00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name PV Power A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_power_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_b-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.sma_device_name_pv_power_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Power B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40251E00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name PV Power B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_power_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_c-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.sma_device_name_pv_power_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Power C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40251E00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name PV Power C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_power_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_a-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.sma_device_name_pv_voltage_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Voltage A', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40451F00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name PV Voltage A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_b-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.sma_device_name_pv_voltage_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Voltage B', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40451F00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name PV Voltage B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_c-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.sma_device_name_pv_voltage_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Voltage C', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40451F00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name PV Voltage C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_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.sma_device_name_secure_power_supply_current', + 'has_entity_name': False, + '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': 'SMA Device Name Secure Power Supply Current', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046C700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Secure Power Supply Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_secure_power_supply_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_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.sma_device_name_secure_power_supply_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Secure Power Supply Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046C800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Secure Power Supply Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_secure_power_supply_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_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.sma_device_name_secure_power_supply_voltage', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Secure Power Supply Voltage', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046C600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Secure Power Supply Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_secure_power_supply_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': , + 'entity_id': 'sensor.sma_device_name_status', + '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': 'SMA Device Name Status', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08214800_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Status', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_total_yield-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.sma_device_name_total_yield', + 'has_entity_name': False, + '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': 'SMA Device Name Total Yield', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00260100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_total_yield-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Total Yield', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_total_yield', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': None, + 'entity_id': 'sensor.sma_device_name_voltage_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Voltage L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00464800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': None, + 'entity_id': 'sensor.sma_device_name_voltage_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Voltage L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00464900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_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': None, + 'entity_id': 'sensor.sma_device_name_voltage_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Voltage L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00464A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 5033462d0a6..c8939ef2d64 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -1,6 +1,6 @@ """Test the sma config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pysma.exceptions import ( SmaAuthenticationException, @@ -20,7 +20,7 @@ from . import ( MOCK_DHCP_DISCOVERY, MOCK_DHCP_DISCOVERY_INPUT, MOCK_USER_INPUT, - _patch_async_setup_entry, + MOCK_USER_REAUTH, ) from tests.conftest import MockConfigEntry @@ -38,7 +38,9 @@ DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( ) -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_sma_client: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -47,16 +49,11 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch("pysma.SMA.new_session", return_value=True), - patch("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_USER_INPUT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_USER_INPUT["host"] @@ -75,18 +72,18 @@ async def test_form(hass: HomeAssistant) -> None: ], ) async def test_form_exceptions( - hass: HomeAssistant, exception: Exception, error: str + hass: HomeAssistant, + mock_setup_entry: MockConfigEntry, + 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( - "homeassistant.components.sma.pysma.SMA.new_session", side_effect=exception - ), - _patch_async_setup_entry() as mock_setup_entry, + with patch( + "homeassistant.components.sma.pysma.SMA.new_session", side_effect=exception ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -95,39 +92,34 @@ async def test_form_exceptions( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - assert len(mock_setup_entry.mock_calls) == 0 async def test_form_already_configured( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_sma_client: AsyncMock ) -> None: """Test starting a flow by user when already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, unique_id="123456789") + 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" - 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() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert len(mock_setup_entry.mock_calls) == 0 -async def test_dhcp_discovery(hass: HomeAssistant) -> None: +async def test_dhcp_discovery( + hass: HomeAssistant, mock_setup_entry: MockConfigEntry, mock_sma_client: AsyncMock +) -> None: """Test we can setup from dhcp discovery.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -138,31 +130,22 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: 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, - ) + 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.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY_DUPLICATE ) @@ -181,18 +164,23 @@ async def test_dhcp_already_configured( ], ) async def test_dhcp_exceptions( - hass: HomeAssistant, exception: Exception, error: str + hass: HomeAssistant, + mock_setup_entry: MockConfigEntry, + mock_sma_client: AsyncMock, + exception: Exception, + error: str, ) -> None: - """Test we handle cannot connect error.""" + """Test we handle cannot connect error in DHCP flow.""" 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 - ): + with patch("homeassistant.components.sma.pysma.SMA") as mock_sma: + mock_sma_instance = mock_sma.return_value + mock_sma_instance.new_session = AsyncMock(side_effect=exception) + result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_DHCP_DISCOVERY_INPUT, @@ -201,17 +189,12 @@ async def test_dhcp_exceptions( 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(), - ): + with patch("homeassistant.components.sma.pysma.SMA") as mock_sma: + mock_sma_instance = mock_sma.return_value + mock_sma_instance.new_session = AsyncMock(return_value=True) + mock_sma_instance.device_info = AsyncMock(return_value=MOCK_DEVICE) + mock_sma_instance.close_session = AsyncMock(return_value=True) + result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_DHCP_DISCOVERY_INPUT, @@ -221,3 +204,80 @@ async def test_dhcp_exceptions( assert result["title"] == MOCK_DHCP_DISCOVERY["host"] assert result["data"] == MOCK_DHCP_DISCOVERY assert result["result"].unique_id == DHCP_DISCOVERY.hostname.replace("SMA", "") + + +async def test_full_flow_reauth( + hass: HomeAssistant, mock_setup_entry: MockConfigEntry, mock_sma_client: AsyncMock +) -> None: + """Test the full flow of the config flow.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, unique_id="123456789") + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # There is no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_REAUTH, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test we handle errors during reauth flow properly.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, unique_id="123456789") + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + with patch("homeassistant.components.sma.pysma.SMA") as mock_sma: + mock_sma_instance = mock_sma.return_value + mock_sma_instance.new_session = AsyncMock(side_effect=exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_REAUTH, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + assert result["step_id"] == "reauth_confirm" + + mock_sma_instance.new_session = AsyncMock(return_value=True) + mock_sma_instance.device_info = AsyncMock(return_value=MOCK_DEVICE) + mock_sma_instance.close_session = AsyncMock(return_value=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_REAUTH, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/sma/test_diagnostics.py b/tests/components/sma/test_diagnostics.py index 6c1fe0dc5cb..fa65ca049be 100644 --- a/tests/components/sma/test_diagnostics.py +++ b/tests/components/sma/test_diagnostics.py @@ -1,6 +1,6 @@ """Test the SMA diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/sma/test_init.py b/tests/components/sma/test_init.py index 0cc82f49a41..57c3cab33e7 100644 --- a/tests/components/sma/test_init.py +++ b/tests/components/sma/test_init.py @@ -1,27 +1,32 @@ """Test the sma init file.""" +from collections.abc import AsyncGenerator + from homeassistant.components.sma.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import HomeAssistant -from . import MOCK_DEVICE, MOCK_USER_INPUT, _patch_async_setup_entry +from . import MOCK_DEVICE, MOCK_USER_INPUT from tests.common import MockConfigEntry -async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: +async def test_migrate_entry_minor_version_1_2( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_sma_client: AsyncGenerator, +) -> None: """Test migrating a 1.1 config entry to 1.2.""" - with _patch_async_setup_entry(): - entry = MockConfigEntry( - domain=DOMAIN, - title=MOCK_DEVICE["name"], - unique_id=MOCK_DEVICE["serial"], # Not converted to str - data=MOCK_USER_INPUT, - source=SOURCE_IMPORT, - minor_version=1, - ) - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - assert entry.version == 1 - assert entry.minor_version == 2 - assert entry.unique_id == str(MOCK_DEVICE["serial"]) + entry = MockConfigEntry( + domain=DOMAIN, + title=MOCK_DEVICE["name"], + unique_id=MOCK_DEVICE["serial"], # Not converted to str + data=MOCK_USER_INPUT, + source=SOURCE_IMPORT, + minor_version=1, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == str(MOCK_DEVICE["serial"]) diff --git a/tests/components/sma/test_sensor.py b/tests/components/sma/test_sensor.py index de7e1167f1f..8199e8fc163 100644 --- a/tests/components/sma/test_sensor.py +++ b/tests/components/sma/test_sensor.py @@ -1,31 +1,34 @@ -"""Test the sma sensor platform.""" +"""Test the SMA sensor platform.""" -from pysma.const import ( - ENERGY_METER_VIA_INVERTER, - GENERIC_SENSORS, - OPTIMIZERS_VIA_INVERTER, -) -from pysma.definitions import sensor_map +from collections.abc import Generator +from unittest.mock import patch -from homeassistant.components.sma.sensor import SENSOR_ENTITIES -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower +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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform -async def test_sensors(hass: HomeAssistant, init_integration) -> None: - """Test states of the sensors.""" - state = hass.states.get("sensor.sma_device_grid_power") - assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - - -async def test_sensor_entities(hass: HomeAssistant, init_integration) -> None: - """Test SENSOR_ENTITIES contains a SensorEntityDescription for each pysma sensor.""" - pysma_sensor_definitions = ( - sensor_map[GENERIC_SENSORS] - + sensor_map[OPTIMIZERS_VIA_INVERTER] - + sensor_map[ENERGY_METER_VIA_INVERTER] - ) - - for sensor in pysma_sensor_definitions: - assert sensor.name in SENSOR_ENTITIES +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_sma_client: Generator, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.sma.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/smarla/__init__.py b/tests/components/smarla/__init__.py new file mode 100644 index 00000000000..df4a735c0ca --- /dev/null +++ b/tests/components/smarla/__init__.py @@ -0,0 +1,22 @@ +"""Tests for the Smarla integration.""" + +from typing import Any +from unittest.mock import AsyncMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> bool: + """Set up the component.""" + config_entry.add_to_hass(hass) + if success := await hass.config_entries.async_setup(config_entry.entry_id): + await hass.async_block_till_done() + return success + + +async def update_property_listeners(mock: AsyncMock, value: Any = None) -> None: + """Update the property listeners for the mock object.""" + for call in mock.add_listener.call_args_list: + await call[0][0](value) diff --git a/tests/components/smarla/conftest.py b/tests/components/smarla/conftest.py new file mode 100644 index 00000000000..a188924415a --- /dev/null +++ b/tests/components/smarla/conftest.py @@ -0,0 +1,63 @@ +"""Configuration for smarla tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from pysmarlaapi.classes import AuthToken +import pytest + +from homeassistant.components.smarla.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER + +from .const import MOCK_ACCESS_TOKEN_JSON, MOCK_SERIAL_NUMBER, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_SERIAL_NUMBER, + source=SOURCE_USER, + data=MOCK_USER_INPUT, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator: + """Override async_setup_entry.""" + with patch("homeassistant.components.smarla.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_connection() -> Generator[MagicMock]: + """Patch Connection object.""" + with ( + patch( + "homeassistant.components.smarla.config_flow.Connection", autospec=True + ) as mock_connection, + patch( + "homeassistant.components.smarla.Connection", + mock_connection, + ), + ): + connection = mock_connection.return_value + connection.token = AuthToken.from_json(MOCK_ACCESS_TOKEN_JSON) + connection.refresh_token.return_value = True + yield connection + + +@pytest.fixture +def mock_federwiege(mock_connection: MagicMock) -> Generator[MagicMock]: + """Mock the Federwiege instance.""" + with patch( + "homeassistant.components.smarla.Federwiege", autospec=True + ) as mock_federwiege: + federwiege = mock_federwiege.return_value + federwiege.serial_number = MOCK_SERIAL_NUMBER + yield federwiege diff --git a/tests/components/smarla/const.py b/tests/components/smarla/const.py new file mode 100644 index 00000000000..33cb51c63d1 --- /dev/null +++ b/tests/components/smarla/const.py @@ -0,0 +1,20 @@ +"""Constants for the Smarla integration tests.""" + +import base64 +import json + +from homeassistant.const import CONF_ACCESS_TOKEN + +MOCK_ACCESS_TOKEN_JSON = { + "refreshToken": "test", + "appIdentifier": "HA-test", + "serialNumber": "ABCD", +} + +MOCK_SERIAL_NUMBER = MOCK_ACCESS_TOKEN_JSON["serialNumber"] + +MOCK_ACCESS_TOKEN = base64.b64encode( + json.dumps(MOCK_ACCESS_TOKEN_JSON).encode() +).decode() + +MOCK_USER_INPUT = {CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN} diff --git a/tests/components/smarla/snapshots/test_switch.ambr b/tests/components/smarla/snapshots/test_switch.ambr new file mode 100644 index 00000000000..f73981b55ea --- /dev/null +++ b/tests/components/smarla/snapshots/test_switch.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_entities[switch.smarla-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.smarla', + 'has_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': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCD-swing_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.smarla-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla', + }), + 'context': , + 'entity_id': 'switch.smarla', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.smarla_smart_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.smarla_smart_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': 'Smart Mode', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_mode', + 'unique_id': 'ABCD-smart_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.smarla_smart_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Smart Mode', + }), + 'context': , + 'entity_id': 'switch.smarla_smart_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smarla/test_config_flow.py b/tests/components/smarla/test_config_flow.py new file mode 100644 index 00000000000..a2bd5b36fc0 --- /dev/null +++ b/tests/components/smarla/test_config_flow.py @@ -0,0 +1,102 @@ +"""Test config flow for Swing2Sleep Smarla integration.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.smarla.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_SERIAL_NUMBER, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_config_flow( + hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock +) -> None: + """Test creating a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_SERIAL_NUMBER + assert result["data"] == MOCK_USER_INPUT + assert result["result"].unique_id == MOCK_SERIAL_NUMBER + + +async def test_malformed_token( + hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock +) -> None: + """Test we show user form on malformed token input.""" + with patch( + "homeassistant.components.smarla.config_flow.Connection", side_effect=ValueError + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "malformed_token"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_invalid_auth( + hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock +) -> None: + """Test we show user form on invalid auth.""" + with patch.object( + mock_connection, "refresh_token", new=AsyncMock(return_value=False) + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_device_exists_abort( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock +) -> None: + """Test we abort config flow if Smarla device already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/smarla/test_init.py b/tests/components/smarla/test_init.py new file mode 100644 index 00000000000..b9d291f582d --- /dev/null +++ b/tests/components/smarla/test_init.py @@ -0,0 +1,21 @@ +"""Test switch platform for Swing2Sleep Smarla integration.""" + +from unittest.mock import MagicMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_init_invalid_auth( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock +) -> None: + """Test init invalid authentication behavior.""" + mock_connection.refresh_token.return_value = False + + assert not await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/smarla/test_switch.py b/tests/components/smarla/test_switch.py new file mode 100644 index 00000000000..24a645dac9f --- /dev/null +++ b/tests/components/smarla/test_switch.py @@ -0,0 +1,103 @@ +"""Test switch platform for Swing2Sleep Smarla integration.""" + +from unittest.mock import MagicMock, patch + +from pysmarlaapi.federwiege.classes import Property +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 . import setup_integration, update_property_listeners + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def mock_switch_property() -> MagicMock: + """Mock a switch property.""" + mock = MagicMock(spec=Property) + mock.get.return_value = False + return mock + + +async def test_entities( + hass: HomeAssistant, + mock_federwiege: MagicMock, + mock_switch_property: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Smarla entities.""" + mock_federwiege.get_property.return_value = mock_switch_property + + with ( + patch("homeassistant.components.smarla.PLATFORMS", [Platform.SWITCH]), + ): + assert await setup_integration(hass, mock_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("service", "parameter"), + [ + (SERVICE_TURN_ON, True), + (SERVICE_TURN_OFF, False), + ], +) +async def test_switch_action( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_federwiege: MagicMock, + mock_switch_property: MagicMock, + service: str, + parameter: bool, +) -> None: + """Test Smarla Switch on/off behavior.""" + mock_federwiege.get_property.return_value = mock_switch_property + + assert await setup_integration(hass, mock_config_entry) + + # Turn on + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: "switch.smarla"}, + blocking=True, + ) + mock_switch_property.set.assert_called_once_with(parameter) + + +async def test_switch_state_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_federwiege: MagicMock, + mock_switch_property: MagicMock, +) -> None: + """Test Smarla Switch callback.""" + mock_federwiege.get_property.return_value = mock_switch_property + + assert await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.smarla").state == STATE_OFF + + mock_switch_property.get.return_value = True + + await update_property_listeners(mock_switch_property) + await hass.async_block_till_done() + + assert hass.states.get("switch.smarla").state == STATE_ON diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index f316db7bef8..3395f7f4673 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, DeviceEvent, DeviceHealthEvent from pysmartthings.models import HealthStatus -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.smartthings.const import MAIN from homeassistant.const import Platform diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 253a01b6d5f..e8cde67122b 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -118,6 +118,9 @@ def mock_smartthings() -> Generator[AsyncMock]: "vd_sensor_light_2023", "iphone", "da_sac_ehs_000001_sub", + "da_sac_ehs_000001_sub_1", + "da_sac_ehs_000002_sub", + "da_ac_ehs_01001", "da_wm_dw_000001", "da_wm_wd_000001", "da_wm_wd_000001_1", @@ -143,6 +146,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ecobee_sensor", "ecobee_thermostat", "ecobee_thermostat_offline", + "sensi_thermostat", "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", @@ -157,6 +161,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "aux_ac", "hw_q80r_soundbar", "gas_meter", + "lumi", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_ehs_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_ehs_01001.json new file mode 100644 index 00000000000..2214ed3c3e6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_ehs_01001.json @@ -0,0 +1,744 @@ +{ + "components": { + "main": { + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 38, + "unit": "C", + "timestamp": "2025-05-14T19:29:59.586Z" + }, + "maximumSetpoint": { + "value": 69, + "unit": "C", + "timestamp": "2025-05-14T19:29:59.586Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["eco", "std", "power", "force"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "airConditionerMode": { + "value": "std", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_DA_AC_EHS_01001_0000", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AEH-WW-TP1-22-AE6000_17240903", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "di": { + "value": "4165c51e-bf6b-c5b6-fd53-127d6248754b", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "n": { + "value": "Samsung EHS", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnmo": { + "value": "TP1X_DA_AC_EHS_01001_0000|10250141|60070110001711034A00010000002000", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "vid": { + "value": "DA-AC-EHS-01001", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "pi": { + "value": "4165c51e-bf6b-c5b6-fd53-127d6248754b", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-04-13T13:07:05.925Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.alwaysOnSensing", + "samsungce.sacDisplayCondition" + ], + "timestamp": "2025-04-13T13:07:09.182Z" + } + }, + "samsungce.sensingOnSuspendMode": { + "sensingOnSuspendMode": { + "value": "unavailable", + "timestamp": "2025-04-13T13:00:53.287Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25010101, + "timestamp": "2025-04-13T13:00:53.287Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "AE0", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 57, + "unit": "C", + "timestamp": "2025-05-14T19:51:09.752Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 56, + "unit": "C", + "timestamp": "2025-05-14T19:29:59.586Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 4053792, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-05-13T23:00:23Z", + "end": "2025-05-14T13:26:17Z" + }, + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.ehsCycleData": { + "outdoor": { + "value": [ + { + "timestamp": "2025-05-13T22:45:05Z", + "data": "0000000050624249410207D002580000FFFF00350032A05A00000000" + }, + { + "timestamp": "2025-05-13T22:50:07Z", + "data": "001400145B683E414102015A02120002FFFF002F007CA06200000000" + }, + { + "timestamp": "2025-05-13T22:55:06Z", + "data": "00000000586643494102000000000000FFFF003D003BA06200000000" + } + ], + "unit": "C", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "indoor": { + "value": [ + { + "timestamp": "2025-05-13T22:45:05Z", + "data": "4B0559590505014264000000000000000001000000021F1C0000007505054B" + }, + { + "timestamp": "2025-05-13T22:50:07Z", + "data": "5C055D5E0505013A64000000000000000001000000021F210000007505054B" + }, + { + "timestamp": "2025-05-13T22:55:06Z", + "data": "49055D5D0505000000000000000000000000000000021F260000007505054B" + } + ], + "unit": "C", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "custom.outingMode": { + "outingMode": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.alwaysOnSensing": { + "origins": { + "value": null + }, + "alwaysOn": { + "value": null + } + }, + "refresh": {}, + "samsungce.ehsFsvSettings": { + "fsvSettings": { + "value": [ + { + "id": "1031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 37, + "maxValue": 75, + "value": 65, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 15, + "maxValue": 37, + "value": 26, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1051", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 50, + "maxValue": 70, + "value": 69, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1052", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 30, + "maxValue": 40, + "value": 38, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2011", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -20, + "maxValue": 5, + "value": -5, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 10, + "maxValue": 20, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2021", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 70, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2022", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 45, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 70, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2091", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2092", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2093", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 1, + "maxValue": 4, + "value": 2, + "isValid": true + }, + { + "id": "3011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 1, + "isValid": true + }, + { + "id": "3071", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 1, + "isValid": true + }, + { + "id": "4012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -15, + "maxValue": 20, + "value": 0, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4021", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "4042", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 5, + "maxValue": 15, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4061", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + } + ], + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "02504A240903", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "02501A24062401,FFFFFFFFFFFFFF", + "description": "Version" + }, + { + "id": "2", + "swType": "Outdoor", + "versionNumber": "02572A23081000,02549A10000800", + "description": "Version" + } + ], + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "custom.energyType": { + "energyType": { + "value": null + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-04-13T13:00:53.287Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-04-13T13:00:53.287Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-04-13T13:00:53.287Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": { + "newVersion": "00000000", + "currentVersion": "00000000", + "moduleType": "mainController" + }, + "timestamp": "2025-05-11T20:13:06.918Z" + }, + "otnDUID": { + "value": "7XCFUCFWT6VB4", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-04-13T13:00:53.287Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-05-11T20:13:06.918Z" + }, + "progress": { + "value": null + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-05-14T19:29:59.586Z" + } + } + }, + "INDOOR1": { + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 18.5, + "unit": "C", + "timestamp": "2025-05-14T19:54:55.948Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 26, + "unit": "C", + "timestamp": "2025-05-14T19:54:55.948Z" + }, + "maximumSetpoint": { + "value": 65, + "unit": "C", + "timestamp": "2025-05-14T19:54:55.948Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "heat", "auto"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-05-14T19:54:55.948Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 35, + "unit": "C", + "timestamp": "2025-05-14T19:54:55.948Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json b/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json index 5ca8f56fbbf..ab836de52ad 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json @@ -9,11 +9,11 @@ }, "samsungce.cooktopHeatingPower": { "manualLevel": { - "value": 0, + "value": 5, "timestamp": "2025-03-26T05:57:23.203Z" }, "heatingMode": { - "value": "manual", + "value": "boost", "timestamp": "2025-03-25T18:18:28.550Z" }, "manualLevelMin": { @@ -95,7 +95,7 @@ "main": { "custom.disabledComponents": { "disabledComponents": { - "value": ["burner-6"], + "value": ["burner-05", "burner-6"], "timestamp": "2025-03-25T18:18:28.464Z" } }, @@ -467,11 +467,11 @@ }, "samsungce.cooktopHeatingPower": { "manualLevel": { - "value": 0, + "value": 2, "timestamp": "2025-03-26T07:27:58.652Z" }, "heatingMode": { - "value": "manual", + "value": "keepWarm", "timestamp": "2025-03-25T18:18:28.550Z" }, "manualLevelMin": { diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json index 6d15aa4696d..09c5a13613a 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json +++ b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json @@ -669,11 +669,11 @@ }, "samsungce.lamp": { "brightnessLevel": { - "value": "off", + "value": "extraHigh", "timestamp": "2025-03-13T21:23:27.659Z" }, "supportedBrightnessLevel": { - "value": ["off", "high"], + "value": ["off", "extraHigh"], "timestamp": "2025-03-13T21:23:27.659Z" } }, diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json index 0c5a883b4f9..57dba2e0259 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json @@ -574,7 +574,7 @@ }, "samsungce.powerCool": { "activated": { - "value": false, + "value": true, "timestamp": "2025-01-19T21:07:55.725Z" } }, diff --git a/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json index e27c6c3de21..a9a991f488c 100644 --- a/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json +++ b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json @@ -10,72 +10,64 @@ "duration": 0, "override": false }, - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-07T08:18:06.705Z" } }, "powerConsumptionReport": { "powerConsumption": { "value": { - "energy": 8193810.0, + "energy": 8901522.0, "deltaEnergy": 0, - "power": 2.539, - "powerEnergy": 0.009404173966911105, - "persistedEnergy": 8193810.0, + "power": 0.015, + "powerEnergy": 0.01082494583328565, + "persistedEnergy": 8901522.0, "energySaved": 0, - "start": "2025-03-09T11:14:44Z", - "end": "2025-03-09T11:14:57Z" + "start": "2025-05-16T11:18:12Z", + "end": "2025-05-16T12:01:29Z" }, - "timestamp": "2025-03-09T11:14:57.338Z" + "timestamp": "2025-05-16T12:01:29.990Z" } }, "samsungce.ehsCycleData": { "outdoor": { "value": [ { - "timestamp": "2025-03-09T02:00:29Z", - "data": "0038003870FF3C3B46020218019A00050000" + "timestamp": "2025-05-15T22:50:49Z", + "data": "0000000051FF4348450207D0000000000000" }, { - "timestamp": "2025-03-09T02:05:29Z", - "data": "0034003471FF3C3C46020218019A00050000" - }, - { - "timestamp": "2025-03-09T02:10:29Z", - "data": "002D002D71FF3D3D460201C9019A00050000" + "timestamp": "2025-05-15T22:55:49Z", + "data": "0000000051FF4448450207D0000000000000" } ], "unit": "C", - "timestamp": "2025-03-09T11:11:30.786Z" + "timestamp": "2025-05-16T07:00:51.349Z" }, "indoor": { "value": [ { - "timestamp": "2025-03-09T02:00:29Z", - "data": "5F055C050505002564000000000000000001FFFF00079440" + "timestamp": "2025-05-15T22:50:49Z", + "data": "47054C0505050000000000000000000000000000000832EB" }, { - "timestamp": "2025-03-09T02:05:29Z", - "data": "60055E050505002563000000000000000001FFFF00079445" - }, - { - "timestamp": "2025-03-09T02:10:29Z", - "data": "61055F050505002560000000000000000001FFFF0007944B" + "timestamp": "2025-05-15T22:55:49Z", + "data": "47054C0505050000000000000000000000000000000832ED" } ], "unit": "C", - "timestamp": "2025-03-09T11:11:30.786Z" + "timestamp": "2025-05-16T07:00:51.349Z" } }, "custom.outingMode": { "outingMode": { "value": "off", - "timestamp": "2025-03-09T08:00:05.571Z" + "timestamp": "2025-05-14T20:05:40.503Z" } }, "samsungce.ehsThermostat": { "connectionState": { "value": "disconnected", - "timestamp": "2025-03-09T08:00:05.562Z" + "timestamp": "2025-05-06T10:47:04.400Z" } }, "refresh": {}, @@ -83,12 +75,12 @@ "minimumSetpoint": { "value": 40, "unit": "C", - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-15T02:34:53.575Z" }, "maximumSetpoint": { "value": 55, "unit": "C", - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-15T02:34:53.575Z" } }, "airConditionerMode": { @@ -97,11 +89,11 @@ }, "supportedAcModes": { "value": ["eco", "std", "force"], - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-07T08:18:06.705Z" }, "airConditionerMode": { "value": "std", - "timestamp": "2025-03-09T08:00:05.562Z" + "timestamp": "2025-05-06T10:47:04.400Z" } }, "samsungce.ehsFsvSettings": { @@ -320,7 +312,7 @@ "isValid": true } ], - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-09T02:16:02.595Z" } }, "execute": { @@ -395,97 +387,97 @@ }, "binaryId": { "value": "SAC_EHS_MONO", - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-16T08:18:08.723Z" } }, "samsungce.sacDisplayCondition": { "switch": { "value": "enabled", - "timestamp": "2025-03-09T08:00:05.514Z" + "timestamp": "2025-05-06T12:30:02.413Z" } }, "switch": { "switch": { "value": "off", - "timestamp": "2025-03-09T11:00:27.522Z" + "timestamp": "2025-05-16T12:01:29.844Z" } }, "ocf": { "st": { - "value": "2025-03-06T08:37:35Z", - "timestamp": "2025-03-09T08:18:05.953Z" + "value": "2025-05-14T18:33:05Z", + "timestamp": "2025-05-16T08:18:07.449Z" }, "mndt": { "value": "", - "timestamp": "2025-03-09T08:18:05.953Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "mnfv": { - "value": "20240611.1", - "timestamp": "2025-03-09T08:18:05.953Z" + "value": "20250317.1", + "timestamp": "2025-05-16T08:18:07.449Z" }, "mnhw": { "value": "", - "timestamp": "2025-03-09T08:18:05.953Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "di": { "value": "1f98ebd0-ac48-d802-7f62-000001200100", - "timestamp": "2025-03-09T08:18:05.955Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "mnsl": { "value": "", - "timestamp": "2025-03-09T08:18:05.953Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "dmv": { "value": "res.1.1.0,sh.1.1.0", - "timestamp": "2025-03-09T08:18:05.955Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "n": { "value": "Eco Heating System", - "timestamp": "2025-03-09T08:18:05.955Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "mnmo": { "value": "SAC_EHS_MONO|220614|61007400001600000400000000000000", - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-16T08:18:08.723Z" }, "vid": { "value": "DA-SAC-EHS-000001-SUB", - "timestamp": "2025-03-09T08:18:05.953Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "mnmn": { "value": "Samsung Electronics", - "timestamp": "2025-03-09T08:18:05.953Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "mnml": { "value": "", - "timestamp": "2025-03-09T08:18:05.953Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "mnpv": { "value": "4.0", - "timestamp": "2025-03-09T08:18:05.953Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "mnos": { "value": "Tizen", - "timestamp": "2025-03-09T08:18:05.953Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "pi": { "value": "1f98ebd0-ac48-d802-7f62-000001200100", - "timestamp": "2025-03-09T08:18:05.953Z" + "timestamp": "2025-05-16T08:18:07.449Z" }, "icv": { "value": "core.1.1.0", - "timestamp": "2025-03-09T08:18:05.955Z" + "timestamp": "2025-05-16T08:18:07.449Z" } }, "remoteControlStatus": { "remoteControlEnabled": { "value": "true", - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-07T08:18:06.705Z" } }, "custom.energyType": { "energyType": { "value": "2.0", - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-03-22T08:18:04.803Z" }, "energySavingSupport": { "value": false, @@ -516,19 +508,24 @@ "samsungce.toggleSwitch": { "switch": { "value": "off", - "timestamp": "2025-03-09T11:00:22.880Z" + "timestamp": "2025-05-16T07:00:23.689Z" } }, "custom.disabledCapabilities": { "disabledCapabilities": { - "value": ["remoteControlStatus", "demandResponseLoadControl"], - "timestamp": "2025-03-09T08:31:30.641Z" + "value": [ + "remoteControlStatus", + "samsungce.ehsCycleData", + "samsungce.systemAirConditionerReservation", + "demandResponseLoadControl" + ], + "timestamp": "2025-05-16T08:18:08.723Z" } }, "samsungce.driverVersion": { "versionNumber": { - "value": 23070101, - "timestamp": "2023-08-02T14:32:26.195Z" + "value": 25010101, + "timestamp": "2025-03-31T04:43:32.104Z" } }, "samsungce.softwareUpdate": { @@ -543,11 +540,11 @@ }, "availableModules": { "value": [], - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-03-22T07:41:31.476Z" }, "newVersionAvailable": { "value": false, - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-07T08:18:06.705Z" }, "operatingState": { "value": null @@ -561,31 +558,31 @@ "value": null }, "temperature": { - "value": 54.3, + "value": 40.8, "unit": "C", - "timestamp": "2025-03-09T10:43:24.134Z" + "timestamp": "2025-05-16T12:12:59.016Z" } }, "custom.deviceReportStateConfiguration": { "reportStateRealtimePeriod": { "value": "enabled", - "timestamp": "2024-11-08T01:41:37.280Z" + "timestamp": "2025-05-08T03:03:38.391Z" }, "reportStateRealtime": { "value": { "state": "disabled" }, - "timestamp": "2025-03-08T12:06:55.069Z" + "timestamp": "2025-05-14T20:25:52.192Z" }, "reportStatePeriod": { "value": "enabled", - "timestamp": "2024-11-08T01:41:37.280Z" + "timestamp": "2025-05-08T03:03:38.391Z" } }, "samsungce.ehsTemperatureReference": { "temperatureReference": { "value": "water", - "timestamp": "2025-03-09T07:15:48.438Z" + "timestamp": "2025-05-06T10:47:04.249Z" } }, "thermostatCoolingSetpoint": { @@ -595,21 +592,91 @@ "coolingSetpoint": { "value": 48, "unit": "C", - "timestamp": "2025-03-09T10:58:50.857Z" + "timestamp": "2025-05-15T02:34:53.575Z" + } + }, + "samsungce.ehsBoosterHeater": { + "status": { + "value": "off", + "timestamp": "2025-05-15T02:34:53.185Z" + } + }, + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "samsungce.sensingOnSuspendMode": { + "sensingOnSuspendMode": { + "value": null + } + }, + "samsungce.ehsDiverterValve": { + "position": { + "value": "room", + "timestamp": "2025-05-16T02:17:59.268Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "DB91-02102A 2025-03-17", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "DB91-02100A 2020-07-10", + "description": "Version" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "DB91-02103B 2022-06-14", + "description": "" + }, + { + "id": "3", + "swType": "Firmware", + "versionNumber": "DB91-02450A 2022-07-06", + "description": "EHS MONO LOWTEMP" + } + ], + "timestamp": "2025-05-07T08:18:06.705Z" } } }, "INDOOR": { + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, "samsungce.ehsThermostat": { "connectionState": { "value": "disconnected", - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-07T08:18:06.705Z" } }, "samsungce.toggleSwitch": { "switch": { "value": "off", - "timestamp": "2025-03-09T11:14:44.775Z" + "timestamp": "2025-05-14T20:05:45.533Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.systemAirConditionerReservation"], + "timestamp": "2025-03-31T04:03:40.028Z" } }, "temperatureMeasurement": { @@ -617,21 +684,27 @@ "value": null }, "temperature": { - "value": 39.2, + "value": 23.1, "unit": "C", - "timestamp": "2025-03-09T11:15:49.852Z" + "timestamp": "2025-05-16T12:29:12.736Z" } }, "custom.thermostatSetpointControl": { "minimumSetpoint": { "value": 25, "unit": "C", - "timestamp": "2025-03-09T07:06:20.699Z" + "timestamp": "2025-05-15T02:34:53.531Z" }, "maximumSetpoint": { "value": 65, "unit": "C", - "timestamp": "2025-03-09T07:06:20.699Z" + "timestamp": "2025-05-06T10:23:24.471Z" + } + }, + "samsungce.ehsDefrostMode": { + "status": { + "value": "off", + "timestamp": "2025-05-07T08:18:06.705Z" } }, "airConditionerMode": { @@ -640,17 +713,17 @@ }, "supportedAcModes": { "value": ["auto", "cool", "heat"], - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-07T08:18:06.705Z" }, "airConditionerMode": { "value": "heat", - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-07T08:18:06.705Z" } }, "samsungce.ehsTemperatureReference": { "temperatureReference": { "value": "water", - "timestamp": "2025-03-09T07:06:20.699Z" + "timestamp": "2025-05-06T10:23:24.471Z" } }, "thermostatCoolingSetpoint": { @@ -660,19 +733,19 @@ "coolingSetpoint": { "value": 25, "unit": "C", - "timestamp": "2025-03-09T11:14:44.734Z" + "timestamp": "2025-05-14T20:05:40.638Z" } }, "samsungce.sacDisplayCondition": { "switch": { "value": "enabled", - "timestamp": "2025-03-09T08:18:06.394Z" + "timestamp": "2025-05-07T08:18:06.705Z" } }, "switch": { "switch": { "value": "off", - "timestamp": "2025-03-09T11:14:57.238Z" + "timestamp": "2025-05-16T08:18:08.723Z" } } } diff --git a/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub_1.json b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub_1.json new file mode 100644 index 00000000000..a6ced0e16e5 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub_1.json @@ -0,0 +1,704 @@ +{ + "components": { + "main": { + "samsungce.ehsBoosterHeater": { + "status": { + "value": "off", + "timestamp": "2025-05-14T22:47:01.955Z" + } + }, + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 23, + "timestamp": "2025-04-14T15:04:59.182Z" + }, + "binaryId": { + "value": "SAC_EHS_MONO", + "timestamp": "2025-05-15T18:27:08.954Z" + } + }, + "switch": { + "switch": { + "value": null + } + }, + "ocf": { + "st": { + "value": "2025-05-14T23:22:43Z", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnfv": { + "value": "20250317.1", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "di": { + "value": "6a7d5349-0a66-0277-058d-000001200101", + "timestamp": "2025-05-14T22:47:01.717Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-05-14T22:47:01.717Z" + }, + "n": { + "value": "Heat Pump", + "timestamp": "2025-05-14T22:47:01.717Z" + }, + "mnmo": { + "value": "SAC_EHS_MONO|231215|61007400001700000400000000000000", + "timestamp": "2025-05-15T18:27:08.954Z" + }, + "vid": { + "value": "DA-SAC-EHS-000001-SUB", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnpv": { + "value": "4.0", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "pi": { + "value": "6a7d5349-0a66-0277-058d-000001200101", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-05-14T22:47:01.717Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "samsungce.systemAirConditionerReservation", + "demandResponseLoadControl" + ], + "timestamp": "2025-05-12T23:01:07.651Z" + } + }, + "samsungce.sensingOnSuspendMode": { + "sensingOnSuspendMode": { + "value": "available", + "timestamp": "2025-04-14T15:04:59.182Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25010101, + "timestamp": "2025-04-14T15:04:59.182Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "samsungce.ehsDiverterValve": { + "position": { + "value": "room", + "timestamp": "2025-05-06T09:03:32.916Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2025-05-06T09:03:32.870Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-05-13T20:54:48.806Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-05-06T09:03:32.870Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-05-06T22:47:03.830Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 297584.0, + "deltaEnergy": 0, + "power": 0.015, + "powerEnergy": 0.004501854166388512, + "persistedEnergy": 297584.0, + "energySaved": 0, + "start": "2025-05-15T20:52:02Z", + "end": "2025-05-15T21:10:02Z" + }, + "timestamp": "2025-05-15T21:10:02.449Z" + } + }, + "samsungce.ehsCycleData": { + "outdoor": { + "value": [ + { + "timestamp": "2025-05-15T21:48:32Z", + "data": "000000005B62414A410207D0000000000000" + }, + { + "timestamp": "2025-05-15T21:53:32Z", + "data": "000000005A61414A410207D0000000000000" + }, + { + "timestamp": "2025-05-15T21:58:32Z", + "data": "000000005960424A420207D0000000000000" + } + ], + "unit": "C", + "timestamp": "2025-05-15T21:02:33.268Z" + }, + "indoor": { + "value": [ + { + "timestamp": "2025-05-15T21:48:32Z", + "data": "48055A050505000000000000000000000000000000008E85" + }, + { + "timestamp": "2025-05-15T21:53:32Z", + "data": "470559050505000000000000000000000000000000008E8B" + }, + { + "timestamp": "2025-05-15T21:58:32Z", + "data": "470559050505000000000000000000000000000000008E90" + } + ], + "unit": "C", + "timestamp": "2025-05-15T21:02:33.268Z" + } + }, + "custom.outingMode": { + "outingMode": { + "value": "off", + "timestamp": "2025-05-06T09:03:32.781Z" + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": null + } + }, + "refresh": {}, + "samsungce.ehsFsvSettings": { + "fsvSettings": { + "value": [ + { + "id": "1031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 37, + "maxValue": 75, + "value": 75, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 15, + "maxValue": 37, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1051", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 50, + "maxValue": 70, + "value": 60, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1052", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 30, + "maxValue": 40, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2011", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -20, + "maxValue": 5, + "value": -2, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 10, + "maxValue": 20, + "value": 15, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2021", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 60, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2022", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 60, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2091", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 1, + "isValid": true + }, + { + "id": "2092", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 1, + "isValid": true + }, + { + "id": "2093", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 1, + "maxValue": 4, + "value": 4, + "isValid": true + }, + { + "id": "3011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "3071", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 1, + "isValid": true + }, + { + "id": "4012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -15, + "maxValue": 20, + "value": 0, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4021", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "4042", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 5, + "maxValue": 15, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4061", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + } + ], + "timestamp": "2025-05-07T18:12:08.200Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": null + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "DB91-02102A 2025-03-17", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "DB91-02100A 2020-07-10", + "description": "Version" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "DB91-02501A 2023-12-15", + "description": "" + }, + { + "id": "3", + "swType": "Firmware", + "versionNumber": "DB91-02572A 2024-07-17", + "description": "EHS MONO LOWTEMP" + } + ], + "timestamp": "2025-05-13T06:57:54.491Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-05-06T09:03:32.949Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-04-14T15:04:59.439Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2025-04-14T15:04:59.418Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-04-14T15:04:59.272Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-05-06T09:03:32.778Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": null + } + } + }, + "INDOOR": { + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "connected", + "timestamp": "2025-05-06T09:03:32.830Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "on", + "timestamp": "2025-05-06T09:03:32.776Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.systemAirConditionerReservation"], + "timestamp": "2025-04-14T15:04:59.182Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 31, + "unit": "C", + "timestamp": "2025-05-15T21:08:08.464Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-05-14T22:23:55.963Z" + }, + "maximumSetpoint": { + "value": 65, + "unit": "C", + "timestamp": "2025-05-06T09:03:32.729Z" + } + }, + "samsungce.ehsDefrostMode": { + "status": { + "value": "off", + "timestamp": "2025-05-06T09:03:32.830Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["auto", "cool", "heat"], + "timestamp": "2025-05-06T09:03:32.830Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-05-06T09:03:32.830Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-05-06T09:03:32.729Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2025-05-14T22:23:55.326Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-05-06T09:03:32.776Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-05-15T18:27:08.950Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000002_sub.json b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000002_sub.json new file mode 100644 index 00000000000..06f91fbe8b3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000002_sub.json @@ -0,0 +1,868 @@ +{ + "components": { + "main": { + "samsungce.ehsBoosterHeater": { + "status": { + "value": "off", + "timestamp": "2025-05-08T10:20:02.885Z" + } + }, + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 40, + "unit": "C", + "timestamp": "2025-05-05T03:39:24.310Z" + }, + "maximumSetpoint": { + "value": 57, + "unit": "C", + "timestamp": "2025-05-05T03:39:24.310Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["eco", "std", "power", "force"], + "timestamp": "2025-01-16T18:03:09.830Z" + }, + "airConditionerMode": { + "value": "std", + "timestamp": "2025-05-09T02:59:47.311Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 22, + "timestamp": "2025-03-31T04:25:24.686Z" + }, + "binaryId": { + "value": "SAC_EHS_SPLIT", + "timestamp": "2025-05-08T18:03:08.376Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-05-09T04:25:00.539Z" + } + }, + "ocf": { + "st": { + "value": "2025-05-04T18:37:15Z", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnfv": { + "value": "20250317.1", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "di": { + "value": "3810e5ad-5351-d9f9-12ff-000001200000", + "timestamp": "2025-05-08T18:03:08.220Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-05-08T18:03:08.220Z" + }, + "n": { + "value": "Eco Heating System", + "timestamp": "2025-05-08T18:03:08.220Z" + }, + "mnmo": { + "value": "SAC_EHS_SPLIT|220614|61007300001600000400000000000000", + "timestamp": "2025-05-08T18:03:08.376Z" + }, + "vid": { + "value": "DA-SAC-EHS-000002-SUB", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnpv": { + "value": "4.0", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "pi": { + "value": "3810e5ad-5351-d9f9-12ff-000001200000", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-05-08T18:03:08.220Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "on", + "timestamp": "2025-01-18T15:00:57.101Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "thermostatHeatingSetpoint", + "samsungce.systemAirConditionerReservation", + "demandResponseLoadControl" + ], + "timestamp": "2025-04-01T04:45:26.332Z" + } + }, + "samsungce.sensingOnSuspendMode": { + "sensingOnSuspendMode": { + "value": "available", + "timestamp": "2025-03-31T04:25:24.686Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25010101, + "timestamp": "2025-03-31T05:10:13.818Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 49.6, + "unit": "C", + "timestamp": "2025-05-09T04:55:51.712Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": null + }, + "heatingSetpointRange": { + "value": null + } + }, + "samsungce.ehsDiverterValve": { + "position": { + "value": "room", + "timestamp": "2025-05-09T03:33:56.476Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.484Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-05-08T20:17:09.388Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.484Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 52, + "unit": "C", + "timestamp": "2025-05-05T03:39:24.310Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-01-16T18:03:09.830Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 9575308.0, + "deltaEnergy": 45.0, + "power": 0.015, + "powerEnergy": 0.22207609332044917, + "persistedEnergy": 9575308.0, + "energySaved": 0, + "start": "2025-05-09T04:39:01Z", + "end": "2025-05-09T05:02:01Z" + }, + "timestamp": "2025-05-09T05:02:01.788Z" + } + }, + "samsungce.ehsCycleData": { + "outdoor": { + "value": [ + { + "timestamp": "2025-05-08T19:43:06Z", + "data": "0000000063753CFF3C020050027600000000" + }, + { + "timestamp": "2025-05-08T19:48:06Z", + "data": "000000005A7442FF3F0201E0000000000000" + }, + { + "timestamp": "2025-05-08T19:53:06Z", + "data": "00000000577441FF3E0201E0000000000000" + } + ], + "unit": "C", + "timestamp": "2025-05-09T04:57:00.361Z" + }, + "indoor": { + "value": [ + { + "timestamp": "2025-05-08T19:43:06Z", + "data": "565856575805002B640000000101000000000000000E0BB2" + }, + { + "timestamp": "2025-05-08T19:48:06Z", + "data": "5155575757050000000000000101000000000000000E0BB7" + }, + { + "timestamp": "2025-05-08T19:53:06Z", + "data": "535556565705002B640000000101000000000000000E0BBA" + } + ], + "unit": "C", + "timestamp": "2025-05-09T04:57:00.361Z" + } + }, + "custom.outingMode": { + "outingMode": { + "value": "off", + "timestamp": "2025-01-16T11:17:32.257Z" + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-01-16T11:17:32.210Z" + } + }, + "refresh": {}, + "samsungce.ehsFsvSettings": { + "fsvSettings": { + "value": [ + { + "id": "1031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 37, + "maxValue": 65, + "value": 43, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 15, + "maxValue": 37, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1051", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 50, + "maxValue": 70, + "value": 57, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1052", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 30, + "maxValue": 40, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2011", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -20, + "maxValue": 5, + "value": -10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 10, + "maxValue": 20, + "value": 20, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2021", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 65, + "value": 37, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2022", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 65, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 65, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 65, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2091", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2092", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2093", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 1, + "maxValue": 4, + "value": 4, + "isValid": true + }, + { + "id": "3011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 1, + "isValid": true + }, + { + "id": "3071", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -15, + "maxValue": 20, + "value": 0, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4021", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "4042", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 5, + "maxValue": 15, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4061", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 1, + "isValid": true + } + ], + "timestamp": "2025-04-25T02:52:46.974Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.a"], + "x.com.samsung.da.modelNum": "SAC_EHS_SPLIT|220614|61007300001600000400000000000000", + "x.com.samsung.da.description": "EHS_TANK", + "x.com.samsung.da.serialNum": "0TYZPAOTC00301P", + "x.com.samsung.da.versionId": "Samsung Electronics", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.number": "DB91-02102A 2023-09-14", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02100A 2020-07-10", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02103B 2022-06-14", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "" + }, + { + "x.com.samsung.da.number": "DB91-02091B 2022-08-02", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "EHS SPLIT" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2024-03-25T19:40:05.820Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.301Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "DB91-02102A 2025-03-17", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "DB91-02100A 2020-07-10", + "description": "Version" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "DB91-02103B 2022-06-14", + "description": "" + }, + { + "id": "3", + "swType": "Firmware", + "versionNumber": "DB91-02091B 2022-08-02", + "description": "EHS SPLIT" + } + ], + "timestamp": "2025-04-28T03:40:34.481Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-01-16T11:17:32.469Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-01-16T18:03:09.830Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2023-10-05T18:12:48.916Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-16T11:17:32.328Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-01-16T11:17:32.328Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-01-16T11:17:32.266Z" + } + } + }, + "INDOOR1": { + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-01-16T11:17:32.378Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "on", + "timestamp": "2025-01-16T11:17:32.176Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.systemAirConditionerReservation"], + "timestamp": "2025-03-31T04:25:24.686Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 31.2, + "unit": "C", + "timestamp": "2025-05-09T04:57:52.869Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -1000, + "unit": "C", + "timestamp": "2025-01-22T11:43:49.976Z" + }, + "maximumSetpoint": { + "value": -1000, + "unit": "C", + "timestamp": "2025-01-22T11:43:49.976Z" + } + }, + "samsungce.ehsDefrostMode": { + "status": { + "value": "off", + "timestamp": "2025-05-07T01:00:50.612Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["auto", "cool", "heat"], + "timestamp": "2025-01-16T11:17:32.378Z" + }, + "airConditionerMode": { + "value": "auto", + "timestamp": "2025-01-22T11:43:43.266Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-01-16T11:17:32.225Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 24, + "unit": "C", + "timestamp": "2025-01-22T11:43:49.976Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.176Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-05-08T18:03:08.376Z" + } + } + }, + "INDOOR2": { + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-01-16T11:17:32.378Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-01-16T11:17:32.247Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.systemAirConditionerReservation"], + "timestamp": "2025-03-31T04:25:24.686Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 29.1, + "unit": "C", + "timestamp": "2025-05-09T04:47:04.597Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -1000, + "unit": "C", + "timestamp": "2025-01-22T11:43:54.947Z" + }, + "maximumSetpoint": { + "value": -1000, + "unit": "C", + "timestamp": "2025-01-22T11:43:54.947Z" + } + }, + "samsungce.ehsDefrostMode": { + "status": { + "value": "off", + "timestamp": "2025-05-07T01:00:50.612Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["auto", "cool", "heat"], + "timestamp": "2025-01-16T11:17:32.378Z" + }, + "airConditionerMode": { + "value": "auto", + "timestamp": "2025-01-22T11:43:43.266Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-01-16T11:17:32.413Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 24, + "unit": "C", + "timestamp": "2025-01-22T11:43:54.947Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.247Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-05-08T18:03:08.376Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/lumi.json b/tests/components/smartthings/fixtures/device_status/lumi.json new file mode 100644 index 00000000000..dc01671f4d9 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/lumi.json @@ -0,0 +1,56 @@ +{ + "components": { + "main": { + "configuration": {}, + "relativeHumidityMeasurement": { + "humidity": { + "value": 27.24, + "unit": "%", + "timestamp": "2025-05-11T23:31:11.979Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": { + "minimum": -58.0, + "maximum": 482.0 + }, + "unit": "F", + "timestamp": "2025-05-07T14:34:47.868Z" + }, + "temperature": { + "value": 76.0, + "unit": "F", + "timestamp": "2025-05-11T23:31:11.904Z" + } + }, + "atmosphericPressureMeasurement": { + "atmosphericPressure": { + "value": 100, + "unit": "kPa", + "timestamp": "2025-05-11T23:31:11.979Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-05-11T23:11:16.463Z" + }, + "type": { + "value": null + } + }, + "legendabsolute60149.atmosPressure": { + "atmosPressure": { + "value": 1004, + "unit": "mBar", + "timestamp": "2025-05-11T23:31:11.979Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/sensi_thermostat.json b/tests/components/smartthings/fixtures/device_status/sensi_thermostat.json new file mode 100644 index 00000000000..103e6631ab1 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/sensi_thermostat.json @@ -0,0 +1,106 @@ +{ + "components": { + "main": { + "thermostatOperatingState": { + "supportedThermostatOperatingStates": { + "value": null + }, + "thermostatOperatingState": { + "value": "idle", + "timestamp": "2025-05-17T14:16:43.740Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 49, + "unit": "%", + "timestamp": "2025-05-17T14:32:56.192Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2022-04-16T19:45:51.006Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-05-17T14:16:10.555Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 74.5, + "unit": "F", + "timestamp": "2025-05-17T14:32:56.192Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 71, + "unit": "F", + "timestamp": "2025-05-17T14:16:12.093Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": "auto", + "data": { + "supportedThermostatFanModes": ["auto", "on", "circulate"] + }, + "timestamp": "2025-05-17T03:45:45.413Z" + }, + "supportedThermostatFanModes": { + "value": ["auto", "on", "circulate"], + "timestamp": "2025-05-17T03:45:45.413Z" + } + }, + "thermostatMode": { + "thermostatMode": { + "value": "auto", + "data": { + "supportedThermostatModes": [ + "off", + "heat", + "cool", + "emergency heat", + "auto" + ] + }, + "timestamp": "2025-05-17T05:45:53.597Z" + }, + "supportedThermostatModes": { + "value": ["off", "heat", "cool", "emergency heat", "auto"], + "timestamp": "2025-05-17T03:45:45.413Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 75, + "unit": "F", + "timestamp": "2025-05-17T14:16:13.677Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_ehs_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_ehs_01001.json new file mode 100644 index 00000000000..61313aac1ca --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_ehs_01001.json @@ -0,0 +1,229 @@ +{ + "items": [ + { + "deviceId": "4165c51e-bf6b-c5b6-fd53-127d6248754b", + "name": "Samsung EHS", + "label": "Heat pump", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-EHS-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "23dad822-0b66-4821-af2d-79ef502f5231", + "ownerId": "9dd8c4fa-c07c-f66d-ccdb-20eca3411b12", + "roomId": "a2d70c20-12aa-48bc-958b-3d47c9b6cffc", + "deviceTypeName": "oic.d.thermostat", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.outingMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.alwaysOnSensing", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.sensingOnSuspendMode", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.ehsFsvSettings", + "version": 1 + }, + { + "id": "samsungce.ehsCycleData", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "INDOOR1", + "label": "INDOOR1", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-04-13T13:00:48.941Z", + "profile": { + "id": "e6f1cf68-e4bf-3e35-9f17-288a4e5ee0cb" + }, + "ocf": { + "ocfDeviceType": "oic.d.thermostat", + "name": "Samsung EHS", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA_AC_EHS_01001_0000|10250141|60070110001711034A00010000002000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AEH-WW-TP1-22-AE6000_17240903", + "vendorId": "DA-AC-EHS-01001", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240221", + "lastSignupTime": "2025-04-13T13:00:48.876846635Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [0.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0], + "visible": false, + "data": null + }, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json index dffe57b3280..25dff2ab2ac 100644 --- a/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json +++ b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json @@ -88,10 +88,26 @@ "id": "samsungce.sacDisplayCondition", "version": 1 }, + { + "id": "samsungce.sensingOnSuspendMode", + "version": 1 + }, { "id": "samsungce.softwareUpdate", "version": 1 }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.ehsBoosterHeater", + "version": 1 + }, + { + "id": "samsungce.ehsDiverterValve", + "version": 1 + }, { "id": "samsungce.ehsFsvSettings", "version": 1 @@ -111,6 +127,10 @@ { "id": "samsungce.toggleSwitch", "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 } ], "categories": [ @@ -118,7 +138,8 @@ "name": "AirConditioner", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "INDOOR", @@ -140,10 +161,18 @@ "id": "airConditionerMode", "version": 1 }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, { "id": "custom.thermostatSetpointControl", "version": 1 }, + { + "id": "samsungce.ehsDefrostMode", + "version": 1 + }, { "id": "samsungce.ehsTemperatureReference", "version": 1 @@ -159,6 +188,10 @@ { "id": "samsungce.toggleSwitch", "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 } ], "categories": [ @@ -166,13 +199,14 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false } ], "createTime": "2023-08-02T14:32:26.006Z", "parentDeviceId": "1f98ebd0-ac48-d802-7f62-12592d8286b7", "profile": { - "id": "54b9789f-2c8c-310d-9e14-9a84903c792b" + "id": "89782721-6841-3ef6-a699-28e069d28b8b" }, "ocf": { "ocfDeviceType": "oic.d.airconditioner", @@ -184,12 +218,13 @@ "platformVersion": "4.0", "platformOS": "Tizen", "hwVersion": "", - "firmwareVersion": "20240611.1", + "firmwareVersion": "20250317.1", "vendorId": "DA-SAC-EHS-000001-SUB", - "vendorResourceClientServerVersion": "3.2.20", + "vendorResourceClientServerVersion": "4.0.54", "lastSignupTime": "2023-08-02T14:32:25.282882Z", - "transferCandidate": false, - "additionalAuthCodeRequired": false + "transferCandidate": true, + "additionalAuthCodeRequired": false, + "modelCode": "" }, "type": "OCF", "restrictionTier": 0, diff --git a/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub_1.json b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub_1.json new file mode 100644 index 00000000000..fd1dd902b1e --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub_1.json @@ -0,0 +1,237 @@ +{ + "items": [ + { + "deviceId": "6a7d5349-0a66-0277-058d-000001200101", + "name": "Heat Pump", + "label": "Heat Pump Main", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-SAC-EHS-000001-SUB", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c411c5a8-ace8-4fa8-bb60-91525ac83273", + "ownerId": "d1da8ead-6b9d-64a2-ca29-2a25e4c259ca", + "roomId": "e6fa0aa4-08e7-45f7-8ec7-35c9c60908f9", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.outingMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.sensingOnSuspendMode", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.ehsBoosterHeater", + "version": 1 + }, + { + "id": "samsungce.ehsDiverterValve", + "version": 1 + }, + { + "id": "samsungce.ehsFsvSettings", + "version": 1 + }, + { + "id": "samsungce.ehsCycleData", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "INDOOR", + "label": "INDOOR", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsDefrostMode", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-04-14T15:04:59.106Z", + "parentDeviceId": "6a7d5349-0a66-0277-058d-7c8a76501360", + "profile": { + "id": "89782721-6841-3ef6-a699-28e069d28b8b" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Heat Pump", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SAC_EHS_MONO|231215|61007400001700000400000000000000", + "platformVersion": "4.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20250317.1", + "vendorId": "DA-SAC-EHS-000001-SUB", + "vendorResourceClientServerVersion": "4.0.54", + "lastSignupTime": "2025-04-14T15:04:58.476041486Z", + "transferCandidate": true, + "additionalAuthCodeRequired": false, + "modelCode": "" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_sac_ehs_000002_sub.json b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000002_sub.json new file mode 100644 index 00000000000..9722c860519 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000002_sub.json @@ -0,0 +1,308 @@ +{ + "items": [ + { + "deviceId": "3810e5ad-5351-d9f9-12ff-000001200000", + "name": "Eco Heating System", + "label": "W\u00e4rmepumpe", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-SAC-EHS-000002-SUB", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "705633c1-64a2-4d54-9205-bbbd4f843d95", + "ownerId": "312d0773-efec-21c8-279f-5b8724f3ae57", + "roomId": "f9fef09a-b829-4eda-897b-dbaf6eebcac3", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.outingMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.sensingOnSuspendMode", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.ehsBoosterHeater", + "version": 1 + }, + { + "id": "samsungce.ehsDiverterValve", + "version": 1 + }, + { + "id": "samsungce.ehsFsvSettings", + "version": 1 + }, + { + "id": "samsungce.ehsCycleData", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "INDOOR1", + "label": "INDOOR1", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsDefrostMode", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "INDOOR2", + "label": "INDOOR2", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsDefrostMode", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2023-10-05T18:12:48.587Z", + "parentDeviceId": "3810e5ad-5351-d9f9-12ff-ed7c35d51a0c", + "profile": { + "id": "5dd2a4b2-981d-3571-96bb-eef6dc19d036" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Eco Heating System", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SAC_EHS_SPLIT|220614|61007300001600000400000000000000", + "platformVersion": "4.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20250317.1", + "vendorId": "DA-SAC-EHS-000002-SUB", + "vendorResourceClientServerVersion": "4.0.54", + "lastSignupTime": "2023-10-05T18:12:47.561228Z", + "transferCandidate": true, + "additionalAuthCodeRequired": false, + "modelCode": "" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [142.0, 36.0, 22.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/lumi.json b/tests/components/smartthings/fixtures/devices/lumi.json new file mode 100644 index 00000000000..2a5b90adfa1 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/lumi.json @@ -0,0 +1,75 @@ +{ + "items": [ + { + "deviceId": "692ea4e9-2022-4ed8-8a57-1b884a59cc38", + "name": "temp-humid-press-therm-battery-05", + "label": "Outdoor Temp", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "cea6ca21-a702-3c43-8fe5-a7872c7a963f", + "deviceManufacturerCode": "LUMI", + "locationId": "96fe7a00-c7f6-440a-940e-77aa81a9af4b", + "ownerId": "eabfbf0b-ba3f-40f5-8dcb-8aaba788f8e3", + "roomId": "1eca2d6d-d15d-4f0e-9e32-8709acb9b3fe", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "atmosphericPressureMeasurement", + "version": 1 + }, + { + "id": "legendabsolute60149.atmosPressure", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "configuration", + "version": 1 + }, + { + "id": "battery", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2024-06-12T21:27:55.959Z", + "parentDeviceId": "61f28b8c-b975-415a-9197-fbc4e441e77a", + "profile": { + "id": "fa7886ec-6139-3357-8f4a-07a66491c173" + }, + "zigbee": { + "eui": "00158D000967924A", + "networkId": "4B01", + "driverId": "c09c02d7-d05d-4bf4-831b-207a1adeae2f", + "executingLocally": true, + "hubId": "61f28b8c-b975-415a-9197-fbc4e441e77a", + "provisioningState": "NONFUNCTIONAL", + "fingerprintType": "ZIGBEE_MANUFACTURER", + "fingerprintId": "lumi.weather" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/sensi_thermostat.json b/tests/components/smartthings/fixtures/devices/sensi_thermostat.json new file mode 100644 index 00000000000..48d2a9c093d --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/sensi_thermostat.json @@ -0,0 +1,78 @@ +{ + "items": [ + { + "deviceId": "2409a73c-918a-4d1f-b4f5-c27468c71d70", + "name": "Sensi Thermostat", + "label": "Thermostat", + "manufacturerName": "0AKf", + "presentationId": "sensi_thermostat", + "deviceManufacturerCode": "Emerson", + "locationId": "fc2fb744-4d34-4276-be33-56bbc6af266e", + "ownerId": "aecdb855-3ab7-9305-c0e3-0dced524e5dc", + "roomId": "025f6d30-c16c-4d11-8be2-03d5f4708d86", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2022-04-16T19:45:50.864Z", + "profile": { + "id": "923a86cc-983f-4cb1-98da-64fb5aa435ca" + }, + "viper": { + "manufacturerName": "Emerson", + "modelName": "1F95U-42WF", + "swVersion": "6004971003", + "endpointAppId": "viper_7722c3c0-dfc1-11e9-9149-4f2618178093" + }, + "type": "VIPER", + "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 61cecdbd364..40784adcec6 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Motion', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_motionSensor_motion_motion', @@ -75,6 +76,7 @@ 'original_name': 'Sound', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_soundSensor_sound_sound', @@ -123,6 +125,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_contactSensor_contact_contact', @@ -171,6 +174,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_main_switch_switch_switch', @@ -219,6 +223,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_samsungce.kidsLock_lockState_lockState', @@ -266,6 +271,7 @@ 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'door', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_samsungce.doorState_doorState_doorState', @@ -314,6 +320,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_switch_switch_switch', @@ -362,6 +369,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -409,6 +417,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.kidsLock_lockState_lockState', @@ -456,6 +465,7 @@ 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'door', 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.doorState_doorState_doorState', @@ -504,6 +514,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -551,6 +562,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.kidsLock_lockState_lockState', @@ -598,6 +610,7 @@ 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'door', 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.doorState_doorState_doorState', @@ -646,6 +659,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -665,54 +679,6 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_cooler_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_cooler_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': 'Cooler door', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'cooler_door', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_contactSensor_contact_contact', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_cooler_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Refrigerator Cooler door', - }), - 'context': , - 'entity_id': 'binary_sensor.refrigerator_cooler_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -741,6 +707,7 @@ 'original_name': 'Freezer door', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'freezer_door', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_freezer_contactSensor_contact_contact', @@ -761,7 +728,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_cooler_door-entry] +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_fridge_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -774,7 +741,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.refrigerator_cooler_door', + 'entity_id': 'binary_sensor.refrigerator_fridge_door', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -786,23 +753,24 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Cooler door', + 'original_name': 'Fridge door', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cooler_door', - 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cooler_contactSensor_contact_contact', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_contactSensor_contact_contact', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_cooler_door-state] +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_fridge_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': 'Refrigerator Cooler door', + 'friendly_name': 'Refrigerator Fridge door', }), 'context': , - 'entity_id': 'binary_sensor.refrigerator_cooler_door', + 'entity_id': 'binary_sensor.refrigerator_fridge_door', 'last_changed': , 'last_reported': , 'last_updated': , @@ -837,6 +805,7 @@ 'original_name': 'CoolSelect+ door', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cool_select_plus_door', 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cvroom_contactSensor_contact_contact', @@ -885,6 +854,7 @@ 'original_name': 'Freezer door', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'freezer_door', 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_freezer_contactSensor_contact_contact', @@ -905,7 +875,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-entry] +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_fridge_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -918,7 +888,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.frigo_cooler_door', + 'entity_id': 'binary_sensor.refrigerator_fridge_door', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -930,23 +900,24 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Cooler door', + 'original_name': 'Fridge door', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cooler_door', - 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_cooler_contactSensor_contact_contact', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cooler_contactSensor_contact_contact', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-state] +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_fridge_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': 'Frigo Cooler door', + 'friendly_name': 'Refrigerator Fridge door', }), 'context': , - 'entity_id': 'binary_sensor.frigo_cooler_door', + 'entity_id': 'binary_sensor.refrigerator_fridge_door', 'last_changed': , 'last_reported': , 'last_updated': , @@ -981,6 +952,7 @@ 'original_name': 'Freezer door', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'freezer_door', 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_freezer_contactSensor_contact_contact', @@ -1001,6 +973,55 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_fridge_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.frigo_fridge_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': 'Fridge door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_door', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_cooler_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_fridge_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Frigo Fridge door', + }), + 'context': , + 'entity_id': 'binary_sensor.frigo_fridge_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1029,6 +1050,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_samsungce.kidsLock_lockState_lockState', @@ -1076,6 +1098,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_switch_switch_switch', @@ -1124,6 +1147,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -1171,6 +1195,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.kidsLock_lockState_lockState', @@ -1190,6 +1215,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_keep_fresh_mode_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.airdresser_keep_fresh_mode_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Keep fresh mode active', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'keep_fresh_mode_active', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.steamClosetKeepFreshMode_operatingState_operatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_keep_fresh_mode_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Keep fresh mode active', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_keep_fresh_mode_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1218,6 +1291,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_switch_switch_switch', @@ -1266,6 +1340,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -1313,6 +1388,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_samsungce.kidsLock_lockState_lockState', @@ -1360,6 +1436,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_switch_switch_switch', @@ -1408,6 +1485,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -1455,6 +1533,7 @@ 'original_name': 'Wrinkle prevent active', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dryer_wrinkle_prevent_active', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent_operatingState_operatingState', @@ -1502,6 +1581,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_samsungce.kidsLock_lockState_lockState', @@ -1549,6 +1629,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_switch_switch_switch', @@ -1597,6 +1678,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -1644,6 +1726,7 @@ 'original_name': 'Wrinkle prevent active', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dryer_wrinkle_prevent_active', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent_operatingState_operatingState', @@ -1691,6 +1774,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_samsungce.kidsLock_lockState_lockState', @@ -1738,6 +1822,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_switch_switch_switch', @@ -1786,6 +1871,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -1833,6 +1919,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.kidsLock_lockState_lockState', @@ -1880,6 +1967,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_switch_switch_switch', @@ -1928,6 +2016,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -1975,6 +2064,7 @@ 'original_name': 'Child lock', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.kidsLock_lockState_lockState', @@ -2022,6 +2112,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_switch_switch_switch', @@ -2070,6 +2161,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -2117,6 +2209,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_switch_switch_switch', @@ -2165,6 +2258,7 @@ 'original_name': 'Remote control', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_control', 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', @@ -2212,6 +2306,7 @@ 'original_name': 'Motion', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_motionSensor_motion_motion', @@ -2260,6 +2355,7 @@ 'original_name': 'Presence', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_presenceSensor_presence_presence', @@ -2308,6 +2404,7 @@ 'original_name': 'Presence', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9_main_presenceSensor_presence_presence', @@ -2356,6 +2453,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_contactSensor_contact_contact', @@ -2404,6 +2502,7 @@ 'original_name': 'Acceleration', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'acceleration', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_accelerationSensor_acceleration_acceleration', @@ -2452,6 +2551,7 @@ 'original_name': 'Moisture', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116_main_waterSensor_water_water', diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr index 4a7c582f608..ad8e0ff276b 100644 --- a/tests/components/smartthings/snapshots/test_button.ambr +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Stop', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_stop', @@ -74,6 +75,7 @@ 'original_name': 'Stop', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop', 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_stop', @@ -121,6 +123,7 @@ 'original_name': 'Stop', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'stop', 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_stop', @@ -168,6 +171,7 @@ 'original_name': 'Reset water filter', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_water_filter', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_custom.waterFilter_resetWaterFilter', @@ -215,6 +219,7 @@ 'original_name': 'Reset water filter', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_water_filter', 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_custom.waterFilter_resetWaterFilter', diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 6f4dd67d7f7..6280bcf6770 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -34,6 +34,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main', @@ -98,6 +99,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main', @@ -128,6 +130,74 @@ 'state': 'heat', }) # --- +# name: test_all_entities[da_ac_ehs_01001][climate.heat_pump_indoor1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 65, + 'min_temp': 26, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.heat_pump_indoor1', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_INDOOR1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][climate.heat_pump_indoor1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 18.5, + 'friendly_name': 'Heat pump INDOOR1', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 65, + 'min_temp': 26, + 'supported_features': , + 'temperature': 35, + }), + 'context': , + 'entity_id': 'climate.heat_pump_indoor1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -178,6 +248,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main', @@ -282,6 +353,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main', @@ -389,6 +461,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main', @@ -489,6 +562,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main', @@ -528,6 +602,276 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_sac_ehs_000001_sub][climate.eco_heating_system_indoor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 65, + 'min_temp': 25, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.eco_heating_system_indoor', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_INDOOR', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][climate.eco_heating_system_indoor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.1, + 'friendly_name': 'Eco Heating System INDOOR', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 65, + 'min_temp': 25, + 'supported_features': , + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.eco_heating_system_indoor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][climate.heat_pump_main_indoor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 65, + 'min_temp': 25, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.heat_pump_main_indoor', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_INDOOR', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][climate.heat_pump_main_indoor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 31, + 'friendly_name': 'Heat Pump Main INDOOR', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 65, + 'min_temp': 25, + 'supported_features': , + 'temperature': 30, + }), + 'context': , + 'entity_id': 'climate.heat_pump_main_indoor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][climate.warmepumpe_indoor1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.warmepumpe_indoor1', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_INDOOR1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][climate.warmepumpe_indoor1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 31.2, + 'friendly_name': 'Wärmepumpe INDOOR1', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.warmepumpe_indoor1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][climate.warmepumpe_indoor2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.warmepumpe_indoor2', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_INDOOR2', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][climate.warmepumpe_indoor2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 29.1, + 'friendly_name': 'Wärmepumpe INDOOR2', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.warmepumpe_indoor2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[ecobee_thermostat][climate.main_floor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -568,6 +912,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main', @@ -640,6 +985,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main', @@ -703,6 +1049,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main', @@ -766,6 +1113,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main', @@ -797,6 +1145,91 @@ 'state': 'heat', }) # --- +# name: test_all_entities[sensi_thermostat][climate.thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'on', + 'circulate', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2409a73c-918a-4d1f-b4f5-c27468c71d70_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensi_thermostat][climate.thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 49, + 'current_temperature': 23.6, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'on', + 'circulate', + ]), + 'friendly_name': 'Thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': 23.9, + 'target_temp_low': 21.7, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- # name: test_all_entities[virtual_thermostat][climate.asd-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -834,6 +1267,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main', diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index 4b5cf705665..ff34a2a1fea 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '571af102-15db-4030-b76b-245a691f74a5_main', @@ -77,6 +78,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main', diff --git a/tests/components/smartthings/snapshots/test_event.ambr b/tests/components/smartthings/snapshots/test_event.ambr index 79c57df5fd7..ef074b24ce5 100644 --- a/tests/components/smartthings/snapshots/test_event.ambr +++ b/tests/components/smartthings/snapshots/test_event.ambr @@ -33,6 +33,7 @@ 'original_name': 'button1', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button1_button', @@ -93,6 +94,7 @@ 'original_name': 'button2', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button2_button', @@ -153,6 +155,7 @@ 'original_name': 'button3', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button3_button', @@ -213,6 +216,7 @@ 'original_name': 'button4', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button4_button', @@ -273,6 +277,7 @@ 'original_name': 'button5', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button5_button', @@ -333,6 +338,7 @@ 'original_name': 'button6', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button6_button', diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 1196118b3b5..10710c88617 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -35,6 +35,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71_main', @@ -95,6 +96,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '6d95a8b7-4ee3-429a-a13a-00ec9354170c_main', diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 596cc487dd5..446eca63fb2 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -332,6 +332,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_ehs_01001] + 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', + '4165c51e-bf6b-c5b6-fd53-127d6248754b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA_AC_EHS_01001_0000', + 'model_id': None, + 'name': 'Heat pump', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AEH-WW-TP1-22-AE6000_17240903', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', @@ -757,7 +790,73 @@ 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': '20240611.1', + 'sw_version': '20250317.1', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_sac_ehs_000001_sub_1] + 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': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '6a7d5349-0a66-0277-058d-000001200101', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SAC_EHS_MONO', + 'model_id': None, + 'name': 'Heat Pump Main', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20250317.1', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_sac_ehs_000002_sub] + 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': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3810e5ad-5351-d9f9-12ff-000001200000', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SAC_EHS_SPLIT', + 'model_id': None, + 'name': 'Wärmepumpe', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20250317.1', 'via_device_id': None, }) # --- @@ -1586,6 +1685,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[lumi] + 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', + '692ea4e9-2022-4ed8-8a57-1b884a59cc38', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Outdoor Temp', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[multipurpose_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', @@ -1619,6 +1751,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[sensi_thermostat] + 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', + '2409a73c-918a-4d1f-b4f5-c27468c71d70', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Emerson', + 'model': '1F95U-42WF', + 'model_id': None, + 'name': 'Thermostat', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '6004971003', + 'via_device_id': None, + }) +# --- # name: test_devices[sensibo_airconditioner_1] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index 6826a555f6a..c54b40ffab9 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -35,6 +35,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '7c16163e-c94e-482f-95f6-139ae0cd9d5e_main', @@ -101,6 +102,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main', @@ -158,6 +160,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298_main', @@ -219,6 +222,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370_main', @@ -300,6 +304,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054_main', diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr index 325ce0cc677..c2cdf9c6375 100644 --- a/tests/components/smartthings/snapshots/test_lock.ambr +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main', diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr index 8eca654abe3..9b7bcba70fb 100644 --- a/tests/components/smartthings/snapshots/test_media_player.ambr +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -35,6 +35,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main', @@ -97,6 +98,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main', @@ -151,6 +153,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main', @@ -205,6 +208,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main', @@ -260,6 +264,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6_main', @@ -316,6 +321,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main', diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr index ee8dd42712a..e02b2ecc9b4 100644 --- a/tests/components/smartthings/snapshots/test_number.ambr +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -1,4 +1,415 @@ # serializer version: 1 +# name: test_all_entities[da_ks_microwave_0101x][number.microwave_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + '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.microwave_fan_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': 'Fan speed', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hood_fan_speed', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_hood_samsungce.hoodFanSpeed_hoodFanSpeed_hoodFanSpeed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][number.microwave_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Fan speed', + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.microwave_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][number.refrigerator_freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': -15.0, + 'min': -23.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.refrigerator_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': 'Freezer temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_temperature', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_freezer_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][number.refrigerator_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Freezer temperature', + 'max': -15.0, + 'min': -23.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][number.refrigerator_fridge_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7.0, + 'min': 1.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.refrigerator_fridge_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': 'Fridge temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_temperature', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][number.refrigerator_fridge_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Fridge temperature', + 'max': 7.0, + 'min': 1.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_fridge_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][number.refrigerator_freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': -15.0, + 'min': -23.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.refrigerator_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': 'Freezer temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_temperature', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_freezer_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][number.refrigerator_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Freezer temperature', + 'max': -15.0, + 'min': -23.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][number.refrigerator_fridge_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7.0, + 'min': 1.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.refrigerator_fridge_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': 'Fridge temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_temperature', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cooler_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][number.refrigerator_fridge_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Fridge temperature', + 'max': 7.0, + 'min': 1.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_fridge_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][number.frigo_freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': -15, + 'min': -23, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.frigo_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': 'Freezer temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_temperature', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_freezer_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][number.frigo_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frigo Freezer temperature', + 'max': -15, + 'min': -23, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.frigo_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][number.frigo_fridge_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.frigo_fridge_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': 'Fridge temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_temperature', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_cooler_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][number.frigo_fridge_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frigo Fridge temperature', + 'max': 7, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.frigo_fridge_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- # name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -32,6 +443,7 @@ 'original_name': 'Rinse cycles', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_rinse_cycles', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', @@ -89,6 +501,7 @@ 'original_name': 'Rinse cycles', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_rinse_cycles', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', @@ -146,6 +559,7 @@ 'original_name': 'Rinse cycles', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_rinse_cycles', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', diff --git a/tests/components/smartthings/snapshots/test_scene.ambr b/tests/components/smartthings/snapshots/test_scene.ambr index fd9abc9fcca..e7b2ac7b9f9 100644 --- a/tests/components/smartthings/snapshots/test_scene.ambr +++ b/tests/components/smartthings/snapshots/test_scene.ambr @@ -27,6 +27,7 @@ 'original_name': 'Away', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '743b0f37-89b8-476c-aedf-eea8ad8cd29d', @@ -77,6 +78,7 @@ 'original_name': 'Home', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f3341e8b-9b32-4509-af2e-4f7c952e98ba', diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 17d8e10d230..7dd57e89c6a 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -1,4 +1,177 @@ # serializer version: 1 +# name: test_all_entities[da_ks_microwave_0101x][select.microwave_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.microwave_lamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lamp', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lamp', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_hood_samsungce.lamp_brightnessLevel_brightnessLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][select.microwave_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Lamp', + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.microwave_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][select.oven_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.oven_lamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lamp', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lamp', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.lamp_brightnessLevel_brightnessLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][select.oven_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Lamp', + 'options': list([ + 'off', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.oven_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][select.vulcan_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'extra_high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.vulcan_lamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lamp', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lamp', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.lamp_brightnessLevel_brightnessLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][select.vulcan_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Lamp', + 'options': list([ + 'off', + 'extra_high', + ]), + }), + 'context': , + 'entity_id': 'select.vulcan_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'extra_high', + }) +# --- # name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -33,6 +206,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_state', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState', @@ -91,6 +265,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_state', 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_machineState_machineState', @@ -149,6 +324,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_state', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState', @@ -207,6 +383,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_state', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_machineState_machineState', @@ -265,6 +442,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_state', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState', @@ -289,6 +467,136 @@ 'state': 'stop', }) # --- +# name: test_all_entities[da_wm_wm_000001][select.washer_soil_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'extra_light', + 'light', + 'normal', + 'heavy', + 'extra_heavy', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.washer_soil_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': 'Soil level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'soil_level', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_custom.washerSoilLevel_washerSoilLevel_washerSoilLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer_soil_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Soil level', + 'options': list([ + 'none', + 'extra_light', + 'light', + 'normal', + 'heavy', + 'extra_heavy', + ]), + }), + 'context': , + 'entity_id': 'select.washer_soil_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer_spin_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'rinse_hold', + 'no_spin', + 'low', + 'medium', + 'high', + 'extra_high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.washer_spin_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': 'Spin level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'spin_level', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_custom.washerSpinLevel_washerSpinLevel_washerSpinLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer_spin_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Spin level', + 'options': list([ + 'rinse_hold', + 'no_spin', + 'low', + 'medium', + 'high', + 'extra_high', + ]), + }), + 'context': , + 'entity_id': 'select.washer_spin_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- # name: test_all_entities[da_wm_wm_000001_1][select.washing_machine-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -323,6 +631,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_state', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_machineState_machineState', @@ -347,6 +656,73 @@ 'state': 'run', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine_spin_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'rinse_hold', + 'no_spin', + '400', + '800', + '1000', + '1200', + '1400', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.washing_machine_spin_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': 'Spin level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'spin_level', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_custom.washerSpinLevel_washerSpinLevel_washerSpinLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine_spin_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Spin level', + 'options': list([ + 'rinse_hold', + 'no_spin', + '400', + '800', + '1000', + '1200', + '1400', + ]), + }), + 'context': , + 'entity_id': 'select.washing_machine_spin_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1400', + }) +# --- # name: test_all_entities[da_wm_wm_01011][select.machine_a_laver-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -381,6 +757,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_state', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_machineState_machineState', @@ -440,6 +817,7 @@ 'original_name': 'Detergent dispense amount', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'detergent_amount', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.autoDispenseDetergent_amount_amount', @@ -500,6 +878,7 @@ 'original_name': 'Flexible compartment dispense amount', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flexible_detergent_amount', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.flexibleAutoDispenseDetergent_amount_amount', @@ -525,6 +904,73 @@ 'state': 'standard', }) # --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_spin_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'rinse_hold', + 'no_spin', + '400', + '800', + '1000', + '1200', + '1400', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.machine_a_laver_spin_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': 'Spin level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'spin_level', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_custom.washerSpinLevel_washerSpinLevel_washerSpinLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_spin_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Spin level', + 'options': list([ + 'rinse_hold', + 'no_spin', + '400', + '800', + '1000', + '1200', + '1400', + ]), + }), + 'context': , + 'entity_id': 'select.machine_a_laver_spin_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- # name: test_all_entities[da_wm_wm_100001][select.washer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -559,6 +1005,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_state', 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_machineState_machineState', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index ad073a1d670..e85ec4620e9 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), '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, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_energyMeter_energy_energy', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_powerMeter_power_power', @@ -133,6 +141,7 @@ 'original_name': 'Voltage', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_voltageMeasurement_voltage_voltage', @@ -178,12 +187,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main_temperatureMeasurement_temperature_temperature', @@ -230,12 +243,16 @@ }), '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, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b_main_energyMeter_energy_energy', @@ -282,12 +299,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b_main_powerMeter_power_power', @@ -338,6 +359,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main_battery_battery_battery', @@ -383,12 +405,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main_temperatureMeasurement_temperature_temperature', @@ -446,6 +472,7 @@ 'original_name': 'Alarm', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm', 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_alarm_alarm_alarm', @@ -500,6 +527,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_battery_battery_battery', @@ -545,12 +573,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main_powerMeter_power_power', @@ -601,6 +633,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_battery_battery_battery', @@ -646,12 +679,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_temperatureMeasurement_temperature_temperature', @@ -704,6 +741,7 @@ 'original_name': 'Air quality', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_airQualitySensor_airQuality_airQuality', @@ -755,6 +793,7 @@ 'original_name': 'Carbon dioxide', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_carbonDioxideMeasurement_carbonDioxide_carbonDioxide', @@ -807,6 +846,7 @@ 'original_name': 'Humidity', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_relativeHumidityMeasurement_humidity_humidity', @@ -857,6 +897,7 @@ 'original_name': 'Odor sensor', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'odor_sensor', 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_odorSensor_odorLevel_odorLevel', @@ -906,6 +947,7 @@ 'original_name': 'PM1', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_veryFineDustSensor_veryFineDustLevel_veryFineDustLevel', @@ -958,6 +1000,7 @@ 'original_name': 'PM10', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_dustLevel_dustLevel', @@ -1010,6 +1053,7 @@ 'original_name': 'PM2.5', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_fineDustLevel_fineDustLevel', @@ -1056,12 +1100,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_temperatureMeasurement_temperature_temperature', @@ -1084,6 +1132,288 @@ 'state': '23.0', }) # --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_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.heat_pump_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat pump Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4053.792', + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_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.heat_pump_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat pump Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_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.heat_pump_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat pump Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_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.heat_pump_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat pump Power', + 'power_consumption_end': '2025-05-14T13:26:17Z', + 'power_consumption_start': '2025-05-13T23:00:23Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_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.heat_pump_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat pump Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1117,6 +1447,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -1172,6 +1503,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -1227,6 +1559,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -1279,6 +1612,7 @@ 'original_name': 'Humidity', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_relativeHumidityMeasurement_humidity_humidity', @@ -1334,6 +1668,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_power_meter', @@ -1391,6 +1726,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -1437,12 +1773,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_temperatureMeasurement_temperature_temperature', @@ -1493,6 +1833,7 @@ 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_audioVolume_volume_volume', @@ -1546,6 +1887,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -1601,6 +1943,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -1656,6 +1999,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -1708,6 +2052,7 @@ 'original_name': 'Humidity', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_relativeHumidityMeasurement_humidity_humidity', @@ -1763,6 +2108,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_power_meter', @@ -1820,6 +2166,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -1866,12 +2213,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_temperatureMeasurement_temperature_temperature', @@ -1922,6 +2273,7 @@ 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_audioVolume_volume_volume', @@ -1975,6 +2327,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -2030,6 +2383,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -2085,6 +2439,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -2137,6 +2492,7 @@ 'original_name': 'Humidity', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_relativeHumidityMeasurement_humidity_humidity', @@ -2192,6 +2548,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_power_meter', @@ -2249,6 +2606,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -2295,12 +2653,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_temperatureMeasurement_temperature_temperature', @@ -2351,6 +2713,7 @@ 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_audioVolume_volume_volume', @@ -2401,6 +2764,7 @@ 'original_name': 'Air quality', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_airQualitySensor_airQuality_airQuality', @@ -2452,6 +2816,7 @@ 'original_name': 'PM10', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_dustLevel_dustLevel', @@ -2504,6 +2869,7 @@ 'original_name': 'PM2.5', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_fineDustLevel_fineDustLevel', @@ -2550,12 +2916,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_temperatureMeasurement_temperature_temperature', @@ -2578,6 +2948,498 @@ 'state': '27', }) # --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_1_heating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_1_heating_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Burner 1 heating mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_mode', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-01_samsungce.cooktopHeatingPower_heatingMode_heatingMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_1_heating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Burner 1 heating mode', + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_1_heating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_1_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': None, + 'entity_id': 'sensor.induction_hob_burner_1_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': 'Burner 1 level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manual_level', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-01_samsungce.cooktopHeatingPower_manualLevel_manualLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_1_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Induction Hob Burner 1 level', + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_1_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_2_heating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_2_heating_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Burner 2 heating mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_mode', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-02_samsungce.cooktopHeatingPower_heatingMode_heatingMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_2_heating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Burner 2 heating mode', + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_2_heating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'boost', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_2_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': None, + 'entity_id': 'sensor.induction_hob_burner_2_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': 'Burner 2 level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manual_level', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-02_samsungce.cooktopHeatingPower_manualLevel_manualLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_2_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Induction Hob Burner 2 level', + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_2_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_3_heating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_3_heating_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Burner 3 heating mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_mode', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-03_samsungce.cooktopHeatingPower_heatingMode_heatingMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_3_heating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Burner 3 heating mode', + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_3_heating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'keep_warm', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_3_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': None, + 'entity_id': 'sensor.induction_hob_burner_3_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': 'Burner 3 level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manual_level', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-03_samsungce.cooktopHeatingPower_manualLevel_manualLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_3_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Induction Hob Burner 3 level', + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_3_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_4_heating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_4_heating_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Burner 4 heating mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_mode', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-04_samsungce.cooktopHeatingPower_heatingMode_heatingMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_4_heating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Burner 4 heating mode', + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_4_heating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_4_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': None, + 'entity_id': 'sensor.induction_hob_burner_4_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': 'Burner 4 level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manual_level', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-04_samsungce.cooktopHeatingPower_manualLevel_manualLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_4_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Induction Hob Burner 4 level', + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_4_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_operating_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'run', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_operating_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': 'Operating state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooktop_operating_state', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_main_custom.cooktopOperatingState_cooktopOperatingState_cooktopOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_operating_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Operating state', + 'options': list([ + 'ready', + 'run', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_operating_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2606,6 +3468,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime', @@ -2674,6 +3537,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState', @@ -2747,6 +3611,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState', @@ -2828,6 +3693,7 @@ 'original_name': 'Oven mode', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenMode_ovenMode_ovenMode', @@ -2875,7 +3741,7 @@ 'state': 'others', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_setpoint-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2888,7 +3754,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_set_point', + 'entity_id': 'sensor.microwave_setpoint', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2897,27 +3763,31 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Set point', + 'original_name': 'Setpoint', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_setpoint-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Microwave Set point', + 'friendly_name': 'Microwave Setpoint', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.microwave_set_point', + 'entity_id': 'sensor.microwave_setpoint', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2948,12 +3818,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_temperatureMeasurement_temperature_temperature', @@ -2973,7 +3847,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17', + 'state': '-17.2222222222222', }) # --- # name: test_all_entities[da_ks_oven_01061][sensor.oven_completion_time-entry] @@ -3004,6 +3878,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_completionTime_completionTime', @@ -3072,6 +3947,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_ovenJobState_ovenJobState', @@ -3145,6 +4021,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_machineState_machineState', @@ -3226,6 +4103,7 @@ 'original_name': 'Oven mode', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenMode_ovenMode_ovenMode', @@ -3273,7 +4151,7 @@ 'state': 'bake', }) # --- -# name: test_all_entities[da_ks_oven_01061][sensor.oven_set_point-entry] +# name: test_all_entities[da_ks_oven_01061][sensor.oven_setpoint-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3286,7 +4164,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.oven_set_point', + 'entity_id': 'sensor.oven_setpoint', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3295,27 +4173,31 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Set point', + 'original_name': 'Setpoint', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ks_oven_01061][sensor.oven_set_point-state] +# name: test_all_entities[da_ks_oven_01061][sensor.oven_setpoint-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Oven Set point', + 'friendly_name': 'Oven Setpoint', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.oven_set_point', + 'entity_id': 'sensor.oven_setpoint', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3346,12 +4228,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_temperatureMeasurement_temperature_temperature', @@ -3402,6 +4288,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_completionTime_completionTime', @@ -3470,6 +4357,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_ovenJobState_ovenJobState', @@ -3543,6 +4431,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_machineState_machineState', @@ -3568,6 +4457,64 @@ 'state': 'running', }) # --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_operating_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'run', + 'ready', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_operating_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': 'Operating state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooktop_operating_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_custom.cooktopOperatingState_cooktopOperatingState_cooktopOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_operating_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Operating state', + 'options': list([ + 'run', + 'ready', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_operating_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- # name: test_all_entities[da_ks_range_0101x][sensor.vulcan_oven_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3624,6 +4571,7 @@ 'original_name': 'Oven mode', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenMode_ovenMode_ovenMode', @@ -3671,7 +4619,7 @@ 'state': 'bake', }) # --- -# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_set_point-entry] +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_setpoint-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3684,7 +4632,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.vulcan_set_point', + 'entity_id': 'sensor.vulcan_setpoint', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3693,31 +4641,35 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Set point', + 'original_name': 'Setpoint', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_set_point-state] +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_setpoint-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Vulcan Set point', + 'friendly_name': 'Vulcan Setpoint', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.vulcan_set_point', + 'entity_id': 'sensor.vulcan_setpoint', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '218', + 'state': '218.333333333333', }) # --- # name: test_all_entities[da_ks_range_0101x][sensor.vulcan_temperature-entry] @@ -3744,12 +4696,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_temperatureMeasurement_temperature_temperature', @@ -3769,7 +4725,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '218', + 'state': '218.333333333333', }) # --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] @@ -3805,6 +4761,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -3860,6 +4817,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -3915,6 +4873,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -3937,6 +4896,118 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_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.refrigerator_freezer_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': 'Freezer temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_temperature', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_freezer_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Freezer temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.7777777777778', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_fridge_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_fridge_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': 'Fridge temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_temperature', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_fridge_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Fridge temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_fridge_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.77777777777778', + }) +# --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3970,6 +5041,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_power_meter', @@ -4027,6 +5099,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -4082,6 +5155,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -4137,6 +5211,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -4192,6 +5267,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -4214,6 +5290,118 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_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.refrigerator_freezer_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': 'Freezer temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_temperature', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_freezer_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Freezer temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.7777777777778', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_fridge_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_fridge_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': 'Fridge temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_temperature', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cooler_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_fridge_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Fridge temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_fridge_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.77777777777778', + }) +# --- # name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4247,6 +5435,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_power_meter', @@ -4304,6 +5493,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -4359,6 +5549,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -4414,6 +5605,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -4469,6 +5661,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -4491,6 +5684,118 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_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.frigo_freezer_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': 'Freezer temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_temperature', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_freezer_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frigo Freezer temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_fridge_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.frigo_fridge_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': 'Fridge temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_temperature', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_cooler_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_fridge_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frigo Fridge temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_fridge_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4524,6 +5829,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_power_meter', @@ -4581,6 +5887,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -4631,6 +5938,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_battery_battery_battery', @@ -4689,6 +5997,7 @@ 'original_name': 'Cleaning mode', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_cleaning_mode', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerCleaningMode_robotCleanerCleaningMode_robotCleanerCleaningMode', @@ -4758,6 +6067,7 @@ 'original_name': 'Movement', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_movement', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerMovement_robotCleanerMovement_robotCleanerMovement', @@ -4825,6 +6135,7 @@ 'original_name': 'Turbo mode', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_turbo_mode', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerTurboMode_robotCleanerTurboMode_robotCleanerTurboMode', @@ -4851,55 +6162,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_cooling_set_point-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.eco_heating_system_cooling_set_point', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cooling set point', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'thermostat_cooling_setpoint', - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_cooling_set_point-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Eco Heating System Cooling set point', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.eco_heating_system_cooling_set_point', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '48', - }) -# --- # name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4933,6 +6195,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -4952,7 +6215,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '8193.81', + 'state': '8901.522', }) # --- # name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_difference-entry] @@ -4988,6 +6251,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -5043,6 +6307,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -5098,6 +6363,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_power_meter', @@ -5109,8 +6375,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Eco Heating System Power', - 'power_consumption_end': '2025-03-09T11:14:57Z', - 'power_consumption_start': '2025-03-09T11:14:44Z', + 'power_consumption_end': '2025-05-16T12:01:29Z', + 'power_consumption_start': '2025-05-16T11:18:12Z', 'state_class': , 'unit_of_measurement': , }), @@ -5119,7 +6385,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.539', + 'state': '0.015', }) # --- # name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power_energy-entry] @@ -5155,6 +6421,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -5174,10 +6441,236 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '9.4041739669111e-06', + 'state': '1.08249458332857e-05', }) # --- -# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_temperature-entry] +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'room', + 'tank', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_valve_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve position', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'diverter_valve_position', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_samsungce.ehsDiverterValve_position_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Eco Heating System Valve position', + 'options': list([ + 'room', + 'tank', + ]), + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'room', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_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.heat_pump_main_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Main Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '297.584', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_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.heat_pump_main_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Main Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_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.heat_pump_main_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Main Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5192,7 +6685,124 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.eco_heating_system_temperature', + 'entity_id': 'sensor.heat_pump_main_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Pump Main Power', + 'power_consumption_end': '2025-05-15T21:10:02Z', + 'power_consumption_start': '2025-05-15T20:52:02Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.015', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_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.heat_pump_main_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Main Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.50185416638851e-06', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'room', + 'tank', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_main_valve_position', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5202,31 +6812,374 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Temperature', + 'original_name': 'Valve position', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_temperatureMeasurement_temperature_temperature', - 'unit_of_measurement': , + 'translation_key': 'diverter_valve_position', + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_samsungce.ehsDiverterValve_position_position', + 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_temperature-state] +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_valve_position-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Eco Heating System Temperature', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'enum', + 'friendly_name': 'Heat Pump Main Valve position', + 'options': list([ + 'room', + 'tank', + ]), }), 'context': , - 'entity_id': 'sensor.eco_heating_system_temperature', + 'entity_id': 'sensor.heat_pump_main_valve_position', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '54.3', + 'state': 'room', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_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.warmepumpe_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wärmepumpe Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9575.308', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_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.warmepumpe_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wärmepumpe Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.045', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_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.warmepumpe_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wärmepumpe Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_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.warmepumpe_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Wärmepumpe Power', + 'power_consumption_end': '2025-05-09T05:02:01Z', + 'power_consumption_start': '2025-05-09T04:39:01Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.015', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_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.warmepumpe_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wärmepumpe Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.000222076093320449', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'room', + 'tank', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warmepumpe_valve_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve position', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'diverter_valve_position', + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_samsungce.ehsDiverterValve_position_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Wärmepumpe Valve position', + 'options': list([ + 'room', + 'tank', + ]), + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'room', }) # --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-entry] @@ -5257,6 +7210,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime', @@ -5310,6 +7264,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -5365,6 +7320,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -5420,6 +7376,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -5483,6 +7440,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dishwasher_job_state', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState', @@ -5549,6 +7507,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dishwasher_machine_state', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState', @@ -5607,6 +7566,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_power_meter', @@ -5664,6 +7624,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -5714,6 +7675,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_completionTime_completionTime', @@ -5767,6 +7729,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -5822,6 +7785,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -5877,6 +7841,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -5945,6 +7910,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dryer_job_state', 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_dryerJobState_dryerJobState', @@ -6016,6 +7982,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dryer_machine_state', 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_machineState_machineState', @@ -6074,6 +8041,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_power_meter', @@ -6131,6 +8099,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -6181,6 +8150,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime', @@ -6234,6 +8204,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -6289,6 +8260,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -6344,6 +8316,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -6412,6 +8385,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dryer_job_state', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState', @@ -6483,6 +8457,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dryer_machine_state', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState', @@ -6541,6 +8516,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_power_meter', @@ -6598,6 +8574,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -6648,6 +8625,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_completionTime_completionTime', @@ -6701,6 +8679,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -6756,6 +8735,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -6811,6 +8791,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -6879,6 +8860,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dryer_job_state', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_dryerJobState_dryerJobState', @@ -6950,6 +8932,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dryer_machine_state', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_machineState_machineState', @@ -7008,6 +8991,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_power_meter', @@ -7065,6 +9049,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -7115,6 +9100,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime', @@ -7168,6 +9154,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -7223,6 +9210,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -7278,6 +9266,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -7347,6 +9336,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_job_state', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState', @@ -7419,6 +9409,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_machine_state', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState', @@ -7477,6 +9468,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_power_meter', @@ -7534,6 +9526,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -7584,6 +9577,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_completionTime_completionTime', @@ -7637,6 +9631,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -7692,6 +9687,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -7747,6 +9743,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -7816,6 +9813,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_job_state', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_washerJobState_washerJobState', @@ -7888,6 +9886,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_machine_state', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_machineState_machineState', @@ -7946,6 +9945,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_power_meter', @@ -8003,6 +10003,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -8053,6 +10054,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_completionTime_completionTime', @@ -8106,6 +10108,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -8161,6 +10164,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -8216,6 +10220,7 @@ 'original_name': 'Energy saved', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_energySaved_meter', @@ -8285,6 +10290,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_job_state', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_washerJobState_washerJobState', @@ -8357,6 +10363,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_machine_state', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_machineState_machineState', @@ -8415,6 +10422,7 @@ 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_power_meter', @@ -8472,6 +10480,7 @@ 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', @@ -8518,12 +10527,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water consumption', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_consumption', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.waterConsumptionReport_waterConsumption_waterConsumption', @@ -8574,6 +10587,7 @@ 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'completion_time', 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_completionTime_completionTime', @@ -8641,6 +10655,7 @@ 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_job_state', 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_washerJobState_washerJobState', @@ -8713,6 +10728,7 @@ 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_machine_state', 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_machineState_machineState', @@ -8762,12 +10778,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_temperatureMeasurement_temperature_temperature', @@ -8787,7 +10807,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '22', + 'state': '21.6666666666667', }) # --- # name: test_all_entities[ecobee_thermostat][sensor.main_floor_humidity-entry] @@ -8820,6 +10840,7 @@ 'original_name': 'Humidity', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main_relativeHumidityMeasurement_humidity_humidity', @@ -8866,12 +10887,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main_temperatureMeasurement_temperature_temperature', @@ -8891,7 +10916,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '22', + 'state': '21.6666666666667', }) # --- # name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_humidity-entry] @@ -8924,6 +10949,7 @@ 'original_name': 'Humidity', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main_relativeHumidityMeasurement_humidity_humidity', @@ -8976,6 +11002,7 @@ 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main_temperatureMeasurement_temperature_temperature', @@ -9021,6 +11048,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -9030,6 +11060,7 @@ 'original_name': 'Gas', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterVolume_gasMeterVolume', @@ -9049,7 +11080,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '40', + 'state': '39.6435852288', }) # --- # name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter-entry] @@ -9076,12 +11107,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Gas meter', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gas_meter', 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeter_gasMeter', @@ -9132,6 +11167,7 @@ 'original_name': 'Gas meter calorific', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gas_meter_calorific', 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterCalorific_gasMeterCalorific', @@ -9179,6 +11215,7 @@ 'original_name': 'Gas meter time', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gas_meter_time', 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterTime_gasMeterTime', @@ -9229,6 +11266,7 @@ 'original_name': 'Link quality', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_quality', 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_signalStrength_lqi_lqi', @@ -9279,6 +11317,7 @@ 'original_name': 'Signal strength', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_signalStrength_rssi_rssi', @@ -9325,12 +11364,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_temperatureMeasurement_temperature_temperature', @@ -9381,6 +11424,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_main_battery_battery_battery', @@ -9426,12 +11470,16 @@ }), '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, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_energyMeter_energy_energy', @@ -9478,12 +11526,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_powerMeter_power_power', @@ -9530,12 +11582,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_temperatureMeasurement_temperature_temperature', @@ -9586,6 +11642,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main_battery_battery_battery', @@ -9607,6 +11664,224 @@ 'state': '37', }) # --- +# name: test_all_entities[lumi][sensor.outdoor_temp_atmospheric_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.outdoor_temp_atmospheric_pressure', + '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': 'Atmospheric pressure', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '692ea4e9-2022-4ed8-8a57-1b884a59cc38_main_atmosphericPressureMeasurement_atmosphericPressure_atmosphericPressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Outdoor Temp Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outdoor_temp_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000.0', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.outdoor_temp_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': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '692ea4e9-2022-4ed8-8a57-1b884a59cc38_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Outdoor Temp Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.outdoor_temp_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_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.outdoor_temp_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': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '692ea4e9-2022-4ed8-8a57-1b884a59cc38_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Outdoor Temp Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.outdoor_temp_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.24', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_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.outdoor_temp_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '692ea4e9-2022-4ed8-8a57-1b884a59cc38_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Outdoor Temp Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outdoor_temp_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.4444444444444', + }) +# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9635,6 +11910,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_battery_battery_battery', @@ -9680,12 +11956,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_temperatureMeasurement_temperature_temperature', @@ -9705,7 +11985,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '19.4', + 'state': '19.4444444444444', }) # --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-entry] @@ -9736,6 +12016,7 @@ 'original_name': 'X coordinate', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'x_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_x_coordinate', @@ -9783,6 +12064,7 @@ 'original_name': 'Y coordinate', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'y_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_y_coordinate', @@ -9830,6 +12112,7 @@ 'original_name': 'Z coordinate', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'z_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_z_coordinate', @@ -9849,6 +12132,115 @@ 'state': '-1042', }) # --- +# name: test_all_entities[sensi_thermostat][sensor.thermostat_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.thermostat_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': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2409a73c-918a-4d1f-b4f5-c27468c71d70_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensi_thermostat][sensor.thermostat_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Thermostat Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostat_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49', + }) +# --- +# name: test_all_entities[sensi_thermostat][sensor.thermostat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2409a73c-918a-4d1f-b4f5-c27468c71d70_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensi_thermostat][sensor.thermostat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.6111111111111', + }) +# --- # name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9877,6 +12269,7 @@ 'original_name': 'Air conditioner mode', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_conditioner_mode', 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_airConditionerMode_airConditionerMode_airConditionerMode', @@ -9896,7 +12289,7 @@ 'state': 'cool', }) # --- -# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-entry] +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_setpoint-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -9909,7 +12302,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_cooling_set_point', + 'entity_id': 'sensor.office_cooling_setpoint', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9918,27 +12311,31 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Cooling set point', + 'original_name': 'Cooling setpoint', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermostat_cooling_setpoint', 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-state] +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_setpoint-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Office Cooling set point', + 'friendly_name': 'Office Cooling setpoint', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_cooling_set_point', + 'entity_id': 'sensor.office_cooling_setpoint', 'last_changed': , 'last_reported': , 'last_updated': , @@ -9978,6 +12375,7 @@ 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_powerConsumptionReport_powerConsumption_energy_meter', @@ -10033,6 +12431,7 @@ 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', @@ -10085,6 +12484,7 @@ 'original_name': 'Brightness intensity', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brightness_intensity', 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605_main_relativeBrightness_brightnessIntensity_brightnessIntensity', @@ -10134,6 +12534,7 @@ 'original_name': 'TV channel', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tv_channel', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_tvChannel_tvChannel_tvChannel', @@ -10181,6 +12582,7 @@ 'original_name': 'TV channel name', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tv_channel_name', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_tvChannel_tvChannelName_tvChannelName', @@ -10228,6 +12630,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main_battery_battery_battery', @@ -10273,12 +12676,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main_temperatureMeasurement_temperature_temperature', @@ -10298,7 +12705,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4734.552604985020', + 'state': '4734.55260498502', }) # --- # name: test_all_entities[virtual_water_sensor][sensor.asd_battery-entry] @@ -10329,6 +12736,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116_main_battery_battery_battery', @@ -10378,6 +12786,7 @@ 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main_battery_battery_battery', diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index e1b68971fb8..1323230e7ea 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_switch_switch_switch', @@ -74,6 +75,7 @@ 'original_name': 'Ice maker', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ice_maker', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_icemaker_switch_switch_switch', @@ -93,6 +95,102 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_power_cool-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.refrigerator_power_cool', + 'has_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 cool', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_cool', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_samsungce.powerCool_activated_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_power_cool-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Power cool', + }), + 'context': , + 'entity_id': 'switch.refrigerator_power_cool', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_power_freeze-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.refrigerator_power_freeze', + 'has_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 freeze', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_freeze', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_samsungce.powerFreeze_activated_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_power_freeze-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Power freeze', + }), + 'context': , + 'entity_id': 'switch.refrigerator_power_freeze', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ref_normal_000001][switch.refrigerator_sabbath_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -105,7 +203,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.refrigerator_sabbath_mode', 'has_entity_name': True, 'hidden_by': None, @@ -121,6 +219,7 @@ 'original_name': 'Sabbath mode', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sabbath_mode', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_samsungce.sabbathMode_status_status', @@ -168,6 +267,7 @@ 'original_name': 'Ice maker', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ice_maker', 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_icemaker_switch_switch_switch', @@ -187,6 +287,198 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_power_cool-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.refrigerator_power_cool', + 'has_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 cool', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_cool', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_samsungce.powerCool_activated_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_power_cool-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Power cool', + }), + 'context': , + 'entity_id': 'switch.refrigerator_power_cool', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_power_freeze-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.refrigerator_power_freeze', + 'has_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 freeze', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_freeze', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_samsungce.powerFreeze_activated_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_power_freeze-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Power freeze', + }), + 'context': , + 'entity_id': 'switch.refrigerator_power_freeze', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_power_cool-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.frigo_power_cool', + 'has_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 cool', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_cool', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_samsungce.powerCool_activated_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_power_cool-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Power cool', + }), + 'context': , + 'entity_id': 'switch.frigo_power_cool', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_power_freeze-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.frigo_power_freeze', + 'has_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 freeze', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_freeze', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_samsungce.powerFreeze_activated_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_power_freeze-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Power freeze', + }), + 'context': , + 'entity_id': 'switch.frigo_power_freeze', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -215,6 +507,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_switch_switch_switch', @@ -234,7 +527,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_sac_ehs_000001_sub][switch.eco_heating_system-entry] +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_auto_cycle_link-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -246,8 +539,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.eco_heating_system', + 'entity_category': , + 'entity_id': 'switch.airdresser_auto_cycle_link', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -259,22 +552,119 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Auto cycle link', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_switch_switch_switch', + 'translation_key': 'auto_cycle_link', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.steamClosetAutoCycleLink_steamClosetAutoCycleLink_steamClosetAutoCycleLink', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_sac_ehs_000001_sub][switch.eco_heating_system-state] +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_auto_cycle_link-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Eco Heating System', + 'friendly_name': 'AirDresser Auto cycle link', }), 'context': , - 'entity_id': 'switch.eco_heating_system', + 'entity_id': 'switch.airdresser_auto_cycle_link', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_keep_fresh_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airdresser_keep_fresh_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': 'Keep fresh mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'keep_fresh_mode', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.steamClosetKeepFreshMode_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_keep_fresh_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Keep fresh mode', + }), + 'context': , + 'entity_id': 'switch.airdresser_keep_fresh_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_sanitize-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.airdresser_sanitize', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sanitize', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sanitize', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.steamClosetSanitizeMode_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_sanitize-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Sanitize', + }), + 'context': , + 'entity_id': 'switch.airdresser_sanitize', 'last_changed': , 'last_reported': , 'last_updated': , @@ -309,6 +699,7 @@ 'original_name': 'Wrinkle prevent', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wrinkle_prevent', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent_dryerWrinklePrevent_dryerWrinklePrevent', @@ -356,6 +747,7 @@ 'original_name': 'Wrinkle prevent', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wrinkle_prevent', 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent_dryerWrinklePrevent_dryerWrinklePrevent', @@ -403,6 +795,7 @@ 'original_name': 'Bubble Soak', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bubble_soak', 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.washerBubbleSoak_status_status', @@ -450,6 +843,7 @@ 'original_name': 'Bubble Soak', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bubble_soak', 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.washerBubbleSoak_status_status', @@ -497,6 +891,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_switch_switch_switch', @@ -544,6 +939,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_switch_switch_switch', @@ -591,6 +987,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398_main_switch_switch_switch', @@ -638,6 +1035,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_switch_switch_switch', @@ -685,6 +1083,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605_main_switch_switch_switch', diff --git a/tests/components/smartthings/snapshots/test_update.ambr b/tests/components/smartthings/snapshots/test_update.ambr index c27a0b9f5fc..3191411a429 100644 --- a/tests/components/smartthings/snapshots/test_update.ambr +++ b/tests/components/smartthings/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': 'Firmware', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main', @@ -87,6 +88,7 @@ 'original_name': 'Firmware', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main', @@ -147,6 +149,7 @@ 'original_name': 'Firmware', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main', @@ -207,6 +210,7 @@ 'original_name': 'Firmware', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main', @@ -267,6 +271,7 @@ 'original_name': 'Firmware', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main', @@ -327,6 +332,7 @@ 'original_name': 'Firmware', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398_main', @@ -387,6 +393,7 @@ 'original_name': 'Firmware', 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main', diff --git a/tests/components/smartthings/snapshots/test_valve.ambr b/tests/components/smartthings/snapshots/test_valve.ambr index f82155c8499..1e291d5913c 100644 --- a/tests/components/smartthings/snapshots/test_valve.ambr +++ b/tests/components/smartthings/snapshots/test_valve.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_main', diff --git a/tests/components/smartthings/snapshots/test_water_heater.ambr b/tests/components/smartthings/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..d52400b9de2 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_water_heater.ambr @@ -0,0 +1,221 @@ +# serializer version: 1 +# name: test_all_entities[da_ac_ehs_01001][water_heater.heat_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 69, + 'min_temp': 38, + 'operation_list': list([ + 'off', + 'eco', + 'heat_pump', + 'performance', + 'high_demand', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.heat_pump', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][water_heater.heat_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': 57, + 'friendly_name': 'Heat pump', + 'max_temp': 69, + 'min_temp': 38, + 'operation_list': list([ + 'off', + 'eco', + 'heat_pump', + 'performance', + 'high_demand', + ]), + 'operation_mode': 'off', + 'supported_features': , + 'target_temp_high': 69, + 'target_temp_low': 38, + 'temperature': 56, + }), + 'context': , + 'entity_id': 'water_heater.heat_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][water_heater.eco_heating_system-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 60.0, + 'min_temp': 40, + 'operation_list': list([ + 'off', + 'eco', + 'heat_pump', + 'high_demand', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.eco_heating_system', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][water_heater.eco_heating_system-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': 40.8, + 'friendly_name': 'Eco Heating System', + 'max_temp': 60.0, + 'min_temp': 40, + 'operation_list': list([ + 'off', + 'eco', + 'heat_pump', + 'high_demand', + ]), + 'operation_mode': 'off', + 'supported_features': , + 'target_temp_high': 55, + 'target_temp_low': 40, + 'temperature': 48, + }), + 'context': , + 'entity_id': 'water_heater.eco_heating_system', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][water_heater.warmepumpe-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 60.0, + 'min_temp': 40, + 'operation_list': list([ + 'off', + 'eco', + 'heat_pump', + 'performance', + 'high_demand', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.warmepumpe', + 'has_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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][water_heater.warmepumpe-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': 49.6, + 'friendly_name': 'Wärmepumpe', + 'max_temp': 60.0, + 'min_temp': 40, + 'operation_list': list([ + 'off', + 'eco', + 'heat_pump', + 'performance', + 'high_demand', + ]), + 'operation_mode': 'heat_pump', + 'supported_features': , + 'target_temp_high': 57, + 'target_temp_low': 40, + 'temperature': 52, + }), + 'context': , + 'entity_id': 'water_heater.warmepumpe', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_pump', + }) +# --- diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 22ca94df81a..ab9531bbef6 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity @@ -51,7 +51,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_fridge_door").state == STATE_OFF await trigger_update( hass, @@ -63,7 +63,7 @@ async def test_state_update( component="cooler", ) - assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_ON + assert hass.states.get("binary_sensor.refrigerator_fridge_door").state == STATE_ON @pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) @@ -75,14 +75,14 @@ async def test_availability( """Test availability.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_fridge_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 + hass.states.get("binary_sensor.refrigerator_fridge_door").state == STATE_UNAVAILABLE ) @@ -90,7 +90,7 @@ async def test_availability( hass, devices, "7db87911-7dce-1cf2-7119-b953432a2f09", HealthStatus.ONLINE ) - assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_fridge_door").state == STATE_OFF @pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) @@ -102,7 +102,7 @@ async def test_availability_at_start( """Test unavailable at boot.""" await setup_integration(hass, mock_config_entry) assert ( - hass.states.get("binary_sensor.refrigerator_cooler_door").state + hass.states.get("binary_sensor.refrigerator_fridge_door").state == STATE_UNAVAILABLE ) @@ -190,7 +190,6 @@ async def test_create_issue_with_items( 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 is not None assert issue.translation_key == f"deprecated_binary_{issue_string}_scripts" @@ -210,7 +209,6 @@ async def test_create_issue_with_items( # 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( @@ -258,7 +256,6 @@ async def test_create_issue( assert hass.states.get(entity_id).state == STATE_OFF - assert len(issue_registry.issues) == 1 issue = issue_registry.async_get_issue(DOMAIN, issue_id) assert issue is not None assert issue.translation_key == f"deprecated_binary_{issue_string}" @@ -277,4 +274,3 @@ 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 diff --git a/tests/components/smartthings/test_button.py b/tests/components/smartthings/test_button.py index 5c5f98912e2..daacee7def1 100644 --- a/tests/components/smartthings/test_button.py +++ b/tests/components/smartthings/test_button.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory from pysmartthings import Capability, Command from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.smartthings import MAIN diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 9e3fa22f55d..6f2325cad78 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, call from pysmartthings import Attribute, Capability, Command, Status from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -16,6 +16,8 @@ from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, @@ -613,7 +615,7 @@ async def test_thermostat_set_fan_mode( ) -@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +@pytest.mark.parametrize("device_fixture", ["sensi_thermostat"]) async def test_thermostat_set_hvac_mode( hass: HomeAssistant, devices: AsyncMock, @@ -625,11 +627,11 @@ async def test_thermostat_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.asd", ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, blocking=True, ) devices.execute_device_command.assert_called_once_with( - "2894dc93-0f11-49cc-8a81-3a684cebebf6", + "2409a73c-918a-4d1f-b4f5-c27468c71d70", Capability.THERMOSTAT_MODE, Command.SET_THERMOSTAT_MODE, MAIN, @@ -865,6 +867,258 @@ async def test_thermostat_state_attributes_update( assert hass.states.get("climate.asd").attributes[state_attribute] == expected_value +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_heat_pump_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test heat pump set HVAC mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.warmepumpe_indoor1", ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + "INDOOR1", + argument="heat", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_heat_pump_hvac_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test heat pump set HVAC mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.warmepumpe_indoor1", ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + Command.OFF, + "INDOOR1", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_heat_pump_hvac_mode_from_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test heat pump set HVAC mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.warmepumpe_indoor2", ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == [ + call( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + Command.ON, + "INDOOR2", + ), + call( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + "INDOOR2", + argument="heat", + ), + ] + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_heat_pump_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test heat pump set temperature.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.AIR_CONDITIONER_MODE, + "heat", + component="INDOOR1", + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.warmepumpe_indoor1", ATTR_TEMPERATURE: 35}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + "INDOOR1", + argument=35, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_heat_pump_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + command: Command, +) -> None: + """Test heat pump turn on/off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: "climate.warmepumpe_indoor1"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + command, + "INDOOR1", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_heat_pump_hvac_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.warmepumpe_indoor1").state == HVACMode.AUTO + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.AIR_CONDITIONER_MODE, + Attribute.AIR_CONDITIONER_MODE, + "cool", + component="INDOOR1", + ) + + assert hass.states.get("climate.warmepumpe_indoor1").state == HVACMode.COOL + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000001_sub"]) +@pytest.mark.parametrize( + ( + "capability", + "attribute", + "value", + "state_attribute", + "original_value", + "expected_value", + ), + [ + ( + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, + ATTR_CURRENT_TEMPERATURE, + 23.1, + 20, + ), + ( + Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.COOLING_SETPOINT, + 20, + ATTR_TEMPERATURE, + 25, + 20, + ), + ( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Attribute.MINIMUM_SETPOINT, + 6, + ATTR_MIN_TEMP, + 25, + 6, + ), + ( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Attribute.MAXIMUM_SETPOINT, + 36, + ATTR_MAX_TEMP, + 65, + 36, + ), + ], + ids=[ + ATTR_CURRENT_TEMPERATURE, + ATTR_TEMPERATURE, + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ], +) +async def test_heat_pump_state_attributes_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + capability: Capability, + attribute: Attribute, + value: Any, + state_attribute: str, + original_value: Any, + expected_value: Any, +) -> None: + """Test state attributes update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("climate.eco_heating_system_indoor").attributes[state_attribute] + == original_value + ) + + await trigger_update( + hass, + devices, + "1f98ebd0-ac48-d802-7f62-000001200100", + capability, + attribute, + value, + component="INDOOR", + ) + + assert ( + hass.states.get("climate.eco_heating_system_indoor").attributes[state_attribute] + == expected_value + ) + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_availability( hass: HomeAssistant, diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index 559c6821204..ad6fc762c3c 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command, Status from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index b28a3a1aff5..4eba6593a7f 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.smartthings.const import DOMAIN diff --git a/tests/components/smartthings/test_event.py b/tests/components/smartthings/test_event.py index b9a6fc8be86..96b66036906 100644 --- a/tests/components/smartthings/test_event.py +++ b/tests/components/smartthings/test_event.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory from pysmartthings import Attribute, Capability from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.event import ATTR_EVENT_TYPES from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index 04196417690..36a453ff595 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Capability, Command from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index fcb962449bf..0b8d2e1e632 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -13,7 +13,7 @@ from pysmartthings import ( Subscription, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 46f8f3ae7a3..0aa818dd7f4 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, call from pysmartthings import Attribute, Capability, Command from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( ATTR_BRIGHTNESS, diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 48e83f479fa..54932e1094e 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.components.smartthings.const import MAIN diff --git a/tests/components/smartthings/test_media_player.py b/tests/components/smartthings/test_media_player.py index e3f3652c0ed..0fb53e642d4 100644 --- a/tests/components/smartthings/test_media_player.py +++ b/tests/components/smartthings/test_media_player.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command, Status from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, diff --git a/tests/components/smartthings/test_number.py b/tests/components/smartthings/test_number.py index fa485776c37..f9dfe4d3228 100644 --- a/tests/components/smartthings/test_number.py +++ b/tests/components/smartthings/test_number.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/smartthings/test_scene.py b/tests/components/smartthings/test_scene.py index 7ef287b9e96..5eb055f96f0 100644 --- a/tests/components/smartthings/test_scene.py +++ b/tests/components/smartthings/test_scene.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform diff --git a/tests/components/smartthings/test_select.py b/tests/components/smartthings/test_select.py index ce3bea08ca2..3e1746331f9 100644 --- a/tests/components/smartthings/test_select.py +++ b/tests/components/smartthings/test_select.py @@ -5,10 +5,11 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, + ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) @@ -95,6 +96,38 @@ async def test_select_option( ) +@pytest.mark.parametrize("device_fixture", ["da_ks_range_0101x"]) +async def test_select_option_map( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.vulcan_lamp") + assert state + assert state.state == "extra_high" + assert state.attributes[ATTR_OPTIONS] == [ + "off", + "extra_high", + ] + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.vulcan_lamp", ATTR_OPTION: "extra_high"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + Capability.SAMSUNG_CE_LAMP, + Command.SET_BRIGHTNESS_LEVEL, + MAIN, + argument="extraHigh", + ) + + @pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) async def test_select_option_without_remote_control( hass: HomeAssistant, diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index ecdcd700cab..a004dec214a 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity @@ -71,6 +71,7 @@ async def test_state_update( "issue_string", "entity_id", "expected_state", + "version", ), [ ( @@ -80,6 +81,7 @@ async def test_state_update( "media_player", "sensor.tv_samsung_8_series_49_media_playback_status", STATE_UNKNOWN, + "2025.10.0", ), ( "vd_stv_2017_k", @@ -88,6 +90,7 @@ async def test_state_update( "media_player", "sensor.tv_samsung_8_series_49_volume", "13", + "2025.10.0", ), ( "vd_stv_2017_k", @@ -96,6 +99,7 @@ async def test_state_update( "media_player", "sensor.tv_samsung_8_series_49_media_input_source", "hdmi1", + "2025.10.0", ), ( "im_speaker_ai_0001", @@ -104,6 +108,7 @@ async def test_state_update( "media_player", "sensor.galaxy_home_mini_media_playback_repeat", "off", + "2025.10.0", ), ( "im_speaker_ai_0001", @@ -112,6 +117,25 @@ async def test_state_update( "media_player", "sensor.galaxy_home_mini_media_playback_shuffle", "disabled", + "2025.10.0", + ), + ( + "da_ac_ehs_01001", + f"4165c51e-bf6b-c5b6-fd53-127d6248754b_{MAIN}_{Capability.TEMPERATURE_MEASUREMENT}_{Attribute.TEMPERATURE}_{Attribute.TEMPERATURE}", + "temperature", + "dhw", + "sensor.temperature", + "57", + "2025.12.0", + ), + ( + "da_ac_ehs_01001", + f"4165c51e-bf6b-c5b6-fd53-127d6248754b_{MAIN}_{Capability.THERMOSTAT_COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}", + "cooling_setpoint", + "dhw", + "sensor.cooling_setpoint", + "56", + "2025.12.0", ), ], ) @@ -126,6 +150,7 @@ async def test_create_issue_with_items( issue_string: str, entity_id: str, expected_state: str, + version: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" issue_id = f"deprecated_{issue_string}_{entity_id}" @@ -180,7 +205,6 @@ async def test_create_issue_with_items( 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 is not None assert issue.translation_key == f"deprecated_{issue_string}_scripts" @@ -189,6 +213,7 @@ async def test_create_issue_with_items( "entity_name": suggested_object_id, "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", } + assert issue.breaks_in_ha_version == version entity_registry.async_update_entity( entity_entry.entity_id, @@ -200,7 +225,6 @@ async def test_create_issue_with_items( # 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( @@ -211,6 +235,7 @@ async def test_create_issue_with_items( "issue_string", "entity_id", "expected_state", + "version", ), [ ( @@ -220,6 +245,7 @@ async def test_create_issue_with_items( "media_player", "sensor.tv_samsung_8_series_49_media_playback_status", STATE_UNKNOWN, + "2025.10.0", ), ( "vd_stv_2017_k", @@ -228,6 +254,7 @@ async def test_create_issue_with_items( "media_player", "sensor.tv_samsung_8_series_49_volume", "13", + "2025.10.0", ), ( "vd_stv_2017_k", @@ -236,6 +263,7 @@ async def test_create_issue_with_items( "media_player", "sensor.tv_samsung_8_series_49_media_input_source", "hdmi1", + "2025.10.0", ), ( "im_speaker_ai_0001", @@ -244,6 +272,7 @@ async def test_create_issue_with_items( "media_player", "sensor.galaxy_home_mini_media_playback_repeat", "off", + "2025.10.0", ), ( "im_speaker_ai_0001", @@ -252,6 +281,25 @@ async def test_create_issue_with_items( "media_player", "sensor.galaxy_home_mini_media_playback_shuffle", "disabled", + "2025.10.0", + ), + ( + "da_ac_ehs_01001", + f"4165c51e-bf6b-c5b6-fd53-127d6248754b_{MAIN}_{Capability.TEMPERATURE_MEASUREMENT}_{Attribute.TEMPERATURE}_{Attribute.TEMPERATURE}", + "temperature", + "dhw", + "sensor.temperature", + "57", + "2025.12.0", + ), + ( + "da_ac_ehs_01001", + f"4165c51e-bf6b-c5b6-fd53-127d6248754b_{MAIN}_{Capability.THERMOSTAT_COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}", + "cooling_setpoint", + "dhw", + "sensor.cooling_setpoint", + "56", + "2025.12.0", ), ], ) @@ -266,6 +314,7 @@ async def test_create_issue( issue_string: str, entity_id: str, expected_state: str, + version: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" issue_id = f"deprecated_{issue_string}_{entity_id}" @@ -282,7 +331,6 @@ async def test_create_issue( assert hass.states.get(entity_id).state == expected_state - assert len(issue_registry.issues) == 1 issue = issue_registry.async_get_issue(DOMAIN, issue_id) assert issue is not None assert issue.translation_key == f"deprecated_{issue_string}" @@ -290,6 +338,7 @@ async def test_create_issue( "entity_id": entity_id, "entity_name": suggested_object_id, } + assert issue.breaks_in_ha_version == version entity_registry.async_update_entity( entity_entry.entity_id, @@ -301,7 +350,6 @@ 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"]) diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 0f759d8e6b5..524e5988de6 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity @@ -110,6 +110,38 @@ async def test_command_switch_turn_on_off( ) +@pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ACTIVATE), + (SERVICE_TURN_OFF, Command.DEACTIVATE), + ], +) +async def test_custom_commands( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test switch turn on and off command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.refrigerator_power_cool"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "7db87911-7dce-1cf2-7119-b953432a2f09", + Capability.SAMSUNG_CE_POWER_COOL, + command, + MAIN, + ) + + @pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) async def test_state_update( hass: HomeAssistant, @@ -256,7 +288,6 @@ async def test_create_issue_with_items( 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 is not None assert issue.translation_key == f"deprecated_switch_{issue_string}_scripts" @@ -276,65 +307,80 @@ async def test_create_issue_with_items( # 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", "device_id", "suggested_object_id", "issue_string"), + ("device_fixture", "device_id", "suggested_object_id", "issue_string", "version"), [ ( "da_ks_cooktop_31001", "808dbd84-f357-47e2-a0cd-3b66fa22d584", "induction_hob", "appliance", + "2025.10.0", ), ( "da_ks_microwave_0101x", "2bad3237-4886-e699-1b90-4a51a3d55c8a", "microwave", "appliance", + "2025.10.0", ), ( "da_wm_dw_000001", "f36dc7ce-cac0-0667-dc14-a3704eb5e676", "dishwasher", "appliance", + "2025.10.0", ), ( "da_wm_sc_000001", "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", "airdresser", "appliance", + "2025.10.0", ), ( "da_wm_wd_000001", "02f7256e-8353-5bdd-547f-bd5b1647e01b", "dryer", "appliance", + "2025.10.0", ), ( "da_wm_wm_000001", "f984b91d-f250-9d42-3436-33f09a422a47", "washer", "appliance", + "2025.10.0", ), ( "hw_q80r_soundbar", "afcf3b91-0000-1111-2222-ddff2a0a6577", "soundbar", "media_player", + "2025.10.0", ), ( "vd_network_audio_002s", "0d94e5db-8501-2355-eb4f-214163702cac", "soundbar_living", "media_player", + "2025.10.0", ), ( "vd_stv_2017_k", "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", "tv_samsung_8_series_49", "media_player", + "2025.10.0", + ), + ( + "da_sac_ehs_000002_sub", + "3810e5ad-5351-d9f9-12ff-000001200000", + "warmepumpe", + "dhw", + "2025.12.0", ), ], ) @@ -347,6 +393,7 @@ async def test_create_issue( device_id: str, suggested_object_id: str, issue_string: str, + version: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = f"switch.{suggested_object_id}" @@ -364,7 +411,6 @@ async def test_create_issue( assert hass.states.get(entity_id).state in [STATE_OFF, STATE_ON] - assert len(issue_registry.issues) == 1 issue = issue_registry.async_get_issue(DOMAIN, issue_id) assert issue is not None assert issue.translation_key == f"deprecated_switch_{issue_string}" @@ -372,6 +418,7 @@ async def test_create_issue( "entity_id": entity_id, "entity_name": suggested_object_id, } + assert issue.breaks_in_ha_version == version entity_registry.async_update_entity( entity_entry.entity_id, @@ -383,7 +430,6 @@ 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"]) diff --git a/tests/components/smartthings/test_update.py b/tests/components/smartthings/test_update.py index e4b360e0398..960e8bfb6d7 100644 --- a/tests/components/smartthings/test_update.py +++ b/tests/components/smartthings/test_update.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.smartthings.const import MAIN from homeassistant.components.update import ( diff --git a/tests/components/smartthings/test_valve.py b/tests/components/smartthings/test_valve.py index 9d2cef65035..9aff2dc09be 100644 --- a/tests/components/smartthings/test_valve.py +++ b/tests/components/smartthings/test_valve.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.smartthings import MAIN from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState diff --git a/tests/components/smartthings/test_water_heater.py b/tests/components/smartthings/test_water_heater.py new file mode 100644 index 00000000000..30c85539d3a --- /dev/null +++ b/tests/components/smartthings/test_water_heater.py @@ -0,0 +1,545 @@ +"""Test for the SmartThings water heater platform.""" + +from unittest.mock import AsyncMock, call + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + WaterHeaterEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + 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_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities( + hass, entity_registry, snapshot, Platform.WATER_HEATER + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.parametrize( + ("operation_mode", "argument"), + [ + (STATE_ECO, "eco"), + (STATE_HEAT_PUMP, "std"), + (STATE_HIGH_DEMAND, "force"), + (STATE_PERFORMANCE, "power"), + ], +) +async def test_set_operation_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + operation_mode: str, + argument: str, +) -> None: + """Test set operation mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_OPERATION_MODE: operation_mode, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_set_operation_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set operation mode to off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_OPERATION_MODE: STATE_OFF, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + Command.OFF, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000001_sub"]) +async def test_set_operation_mode_from_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set operation mode.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("water_heater.eco_heating_system").state == STATE_OFF + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.eco_heating_system", + ATTR_OPERATION_MODE: STATE_ECO, + }, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == [ + call( + "1f98ebd0-ac48-d802-7f62-000001200100", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "1f98ebd0-ac48-d802-7f62-000001200100", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="eco", + ), + ] + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_set_operation_to_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set operation mode to off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_OPERATION_MODE: STATE_OFF, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + Command.OFF, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + command: Command, +) -> None: + """Test turn on and off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + service, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + command, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set operation mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_TEMPERATURE: 56, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=56, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.parametrize( + ("on", "argument"), + [ + (True, "on"), + (False, "off"), + ], +) +async def test_away_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + on: bool, + argument: str, +) -> None: + """Test set away mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_AWAY_MODE: on, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.CUSTOM_OUTING_MODE, + Command.SET_OUTING_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_operation_list_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("water_heater.warmepumpe").attributes[ + ATTR_OPERATION_LIST + ] == [ + STATE_OFF, + STATE_ECO, + STATE_HEAT_PUMP, + STATE_PERFORMANCE, + STATE_HIGH_DEMAND, + ] + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["eco", "force", "power"], + ) + + assert hass.states.get("water_heater.warmepumpe").attributes[ + ATTR_OPERATION_LIST + ] == [ + STATE_OFF, + STATE_ECO, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + ] + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_current_operation_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("water_heater.warmepumpe").state == STATE_HEAT_PUMP + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.AIR_CONDITIONER_MODE, + Attribute.AIR_CONDITIONER_MODE, + "eco", + ) + + assert hass.states.get("water_heater.warmepumpe").state == STATE_ECO + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_switch_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("water_heater.warmepumpe") + assert state.state == STATE_HEAT_PUMP + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + Attribute.SWITCH, + "off", + ) + + state = hass.states.get("water_heater.warmepumpe") + assert state.state == STATE_OFF + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_current_temperature_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_CURRENT_TEMPERATURE] + == 49.6 + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 50.0, + ) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_CURRENT_TEMPERATURE] + == 50.0 + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_target_temperature_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_TEMPERATURE] == 52.0 + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.COOLING_SETPOINT, + 50.0, + ) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_TEMPERATURE] == 50.0 + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.parametrize( + ("attribute", "old_value", "state_attribute"), + [ + (Attribute.MINIMUM_SETPOINT, 40, ATTR_TARGET_TEMP_LOW), + (Attribute.MAXIMUM_SETPOINT, 57, ATTR_TARGET_TEMP_HIGH), + ], +) +async def test_target_temperature_bound_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + attribute: Attribute, + old_value: float, + state_attribute: str, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[state_attribute] + == old_value + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + attribute, + 50.0, + ) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[state_attribute] == 50.0 + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_away_mode_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_AWAY_MODE] + == STATE_OFF + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.CUSTOM_OUTING_MODE, + Attribute.OUTING_MODE, + "on", + ) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_AWAY_MODE] + == STATE_ON + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +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("water_heater.warmepumpe").state == STATE_HEAT_PUMP + + await trigger_health_update( + hass, devices, "3810e5ad-5351-d9f9-12ff-000001200000", HealthStatus.OFFLINE + ) + + assert hass.states.get("water_heater.warmepumpe").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "3810e5ad-5351-d9f9-12ff-000001200000", HealthStatus.ONLINE + ) + + assert hass.states.get("water_heater.warmepumpe").state == STATE_HEAT_PUMP + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +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("water_heater.warmepumpe").state == STATE_UNAVAILABLE diff --git a/tests/components/smarttub/test_init.py b/tests/components/smarttub/test_init.py index b1eac3fd98b..ff27820fca1 100644 --- a/tests/components/smarttub/test_init.py +++ b/tests/components/smarttub/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import patch from smarttub import LoginFailed -from homeassistant.components import smarttub from homeassistant.components.smarttub.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant @@ -61,13 +60,13 @@ async def test_config_passed_to_config_entry( ) -> None: """Test that configured options are loaded via config entry.""" config_entry.add_to_hass(hass) - assert await async_setup_component(hass, smarttub.DOMAIN, config_data) + assert await async_setup_component(hass, DOMAIN, config_data) async def test_unload_entry(hass: HomeAssistant, config_entry) -> None: """Test being able to unload an entry.""" config_entry.add_to_hass(hass) - assert await async_setup_component(hass, smarttub.DOMAIN, {}) is True + assert await async_setup_component(hass, DOMAIN, {}) is True assert await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/smarty/snapshots/test_binary_sensor.ambr b/tests/components/smarty/snapshots/test_binary_sensor.ambr index ad4b61f5070..935abfcfaaf 100644 --- a/tests/components/smarty/snapshots/test_binary_sensor.ambr +++ b/tests/components/smarty/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Alarm', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_alarm', @@ -75,6 +76,7 @@ 'original_name': 'Boost state', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boost_state', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_boost', @@ -122,6 +124,7 @@ 'original_name': 'Warning', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'warning', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_warning', diff --git a/tests/components/smarty/snapshots/test_button.ambr b/tests/components/smarty/snapshots/test_button.ambr index b5b86c80beb..380fb2317c4 100644 --- a/tests/components/smarty/snapshots/test_button.ambr +++ b/tests/components/smarty/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Reset filters timer', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_filters_timer', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_reset_filters_timer', diff --git a/tests/components/smarty/snapshots/test_fan.ambr b/tests/components/smarty/snapshots/test_fan.ambr index 2502bd6f09f..a4f4f8989bd 100644 --- a/tests/components/smarty/snapshots/test_fan.ambr +++ b/tests/components/smarty/snapshots/test_fan.ambr @@ -29,6 +29,7 @@ 'original_name': None, 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'fan', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H', diff --git a/tests/components/smarty/snapshots/test_sensor.ambr b/tests/components/smarty/snapshots/test_sensor.ambr index c32740fa38c..232cce177e3 100644 --- a/tests/components/smarty/snapshots/test_sensor.ambr +++ b/tests/components/smarty/snapshots/test_sensor.ambr @@ -21,12 +21,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Extract air temperature', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'extract_air_temperature', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_extract_air_temperature', @@ -76,6 +80,7 @@ 'original_name': 'Extract fan speed', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'extract_fan_speed', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_extract_fan_speed', @@ -124,6 +129,7 @@ 'original_name': 'Filter days left', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_days_left', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_filter_days_left', @@ -166,12 +172,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outdoor air temperature', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outdoor_air_temperature', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_outdoor_air_temperature', @@ -215,12 +225,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Supply air temperature', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supply_air_temperature', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_supply_air_temperature', @@ -270,6 +284,7 @@ 'original_name': 'Supply fan speed', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supply_fan_speed', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_supply_fan_speed', diff --git a/tests/components/smarty/snapshots/test_switch.ambr b/tests/components/smarty/snapshots/test_switch.ambr index 33c829adf31..b84cbf44be9 100644 --- a/tests/components/smarty/snapshots/test_switch.ambr +++ b/tests/components/smarty/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Boost', 'platform': 'smarty', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boost', 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_boost', diff --git a/tests/components/smarty/test_binary_sensor.py b/tests/components/smarty/test_binary_sensor.py index d28fb44e1ce..5bc81eceb38 100644 --- a/tests/components/smarty/test_binary_sensor.py +++ b/tests/components/smarty/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/smarty/test_button.py b/tests/components/smarty/test_button.py index 0a7b67f2be6..3bb8da82201 100644 --- a/tests/components/smarty/test_button.py +++ b/tests/components/smarty/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/smarty/test_fan.py b/tests/components/smarty/test_fan.py index 2c0135b7aa2..557a1977017 100644 --- a/tests/components/smarty/test_fan.py +++ b/tests/components/smarty/test_fan.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/smarty/test_init.py b/tests/components/smarty/test_init.py index 6468fd74507..27c4e0f5145 100644 --- a/tests/components/smarty/test_init.py +++ b/tests/components/smarty/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.smarty.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/smarty/test_sensor.py b/tests/components/smarty/test_sensor.py index a534a2ebb0f..7ec44886952 100644 --- a/tests/components/smarty/test_sensor.py +++ b/tests/components/smarty/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/smarty/test_switch.py b/tests/components/smarty/test_switch.py index 1a6748e2d23..e90eb09fc39 100644 --- a/tests/components/smarty/test_switch.py +++ b/tests/components/smarty/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( diff --git a/tests/components/smlight/snapshots/test_binary_sensor.ambr b/tests/components/smlight/snapshots/test_binary_sensor.ambr index edb2a914a5d..570bc554313 100644 --- a/tests/components/smlight/snapshots/test_binary_sensor.ambr +++ b/tests/components/smlight/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Ethernet', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ethernet', 'unique_id': 'aa:bb:cc:dd:ee:ff_ethernet', @@ -75,6 +76,7 @@ 'original_name': 'Internet', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'internet', 'unique_id': 'aa:bb:cc:dd:ee:ff_internet', @@ -123,6 +125,7 @@ 'original_name': 'VPN', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vpn', 'unique_id': 'aa:bb:cc:dd:ee:ff_vpn', @@ -171,6 +174,7 @@ 'original_name': 'Wi-Fi', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi', 'unique_id': 'aa:bb:cc:dd:ee:ff_wifi', diff --git a/tests/components/smlight/snapshots/test_sensor.ambr b/tests/components/smlight/snapshots/test_sensor.ambr index 542338e4dbf..d61872b024c 100644 --- a/tests/components/smlight/snapshots/test_sensor.ambr +++ b/tests/components/smlight/snapshots/test_sensor.ambr @@ -33,6 +33,7 @@ 'original_name': 'Connection mode', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_mode', 'unique_id': 'aa:bb:cc:dd:ee:ff_device_mode', @@ -91,6 +92,7 @@ 'original_name': 'Core chip temp', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'core_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff_core_temperature', @@ -141,6 +143,7 @@ 'original_name': 'Core uptime', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'core_uptime', 'unique_id': 'aa:bb:cc:dd:ee:ff_core_uptime', @@ -183,12 +186,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Filesystem usage', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fs_usage', 'unique_id': 'aa:bb:cc:dd:ee:ff_fs_usage', @@ -243,6 +250,7 @@ 'original_name': 'Firmware channel', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'firmware_channel', 'unique_id': 'aa:bb:cc:dd:ee:ff_firmware_channel', @@ -289,12 +297,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RAM usage', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ram_usage', 'unique_id': 'aa:bb:cc:dd:ee:ff_ram_usage', @@ -349,6 +361,7 @@ 'original_name': 'Zigbee chip temp', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'zigbee_temperature', 'unique_id': 'aa:bb:cc:dd:ee:ff_zigbee_temperature', @@ -405,6 +418,7 @@ 'original_name': 'Zigbee type', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'zigbee_type', 'unique_id': 'aa:bb:cc:dd:ee:ff_zigbee_type', @@ -458,6 +472,7 @@ 'original_name': 'Zigbee uptime', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'socket_uptime', 'unique_id': 'aa:bb:cc:dd:ee:ff_socket_uptime', diff --git a/tests/components/smlight/snapshots/test_switch.ambr b/tests/components/smlight/snapshots/test_switch.ambr index b748202a557..85084c73609 100644 --- a/tests/components/smlight/snapshots/test_switch.ambr +++ b/tests/components/smlight/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Auto Zigbee update', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_zigbee_update', 'unique_id': 'aa:bb:cc:dd:ee:ff-auto_zigbee_update', @@ -75,6 +76,7 @@ 'original_name': 'Disable LEDs', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disable_led', 'unique_id': 'aa:bb:cc:dd:ee:ff-disable_led', @@ -123,6 +125,7 @@ 'original_name': 'LED night mode', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'night_mode', 'unique_id': 'aa:bb:cc:dd:ee:ff-night_mode', @@ -171,6 +174,7 @@ 'original_name': 'VPN enabled', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vpn_enabled', 'unique_id': 'aa:bb:cc:dd:ee:ff-vpn_enabled', diff --git a/tests/components/smlight/snapshots/test_update.ambr b/tests/components/smlight/snapshots/test_update.ambr index dc6b8f46ca5..c1c04358ceb 100644 --- a/tests/components/smlight/snapshots/test_update.ambr +++ b/tests/components/smlight/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': 'Core firmware', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'core_update', 'unique_id': 'aa:bb:cc:dd:ee:ff-core_update', @@ -87,6 +88,7 @@ 'original_name': 'Zigbee firmware', 'platform': 'smlight', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'zigbee_update', 'unique_id': 'aa:bb:cc:dd:ee:ff-zigbee_update', diff --git a/tests/components/smlight/test_button.py b/tests/components/smlight/test_button.py index 51e9414c00e..f9ea010fe7c 100644 --- a/tests/components/smlight/test_button.py +++ b/tests/components/smlight/test_button.py @@ -3,18 +3,22 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from pysmlight import Info +from pysmlight import Info, Radio import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.smlight.const import SCAN_INTERVAL +from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .conftest import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) @pytest.fixture @@ -23,7 +27,7 @@ def platforms() -> Platform | list[Platform]: return [Platform.BUTTON] -MOCK_ROUTER = Info(MAC="AA:BB:CC:DD:EE:FF", zb_type=1) +MOCK_ROUTER = Info(MAC="AA:BB:CC:DD:EE:FF", radios=[Radio(zb_type=1)]) @pytest.mark.parametrize( @@ -67,7 +71,7 @@ async def test_buttons( ) assert len(mock_method.mock_calls) == 1 - mock_method.assert_called_with() + mock_method.assert_called() @pytest.mark.parametrize("entity_id", ["zigbee_flash_mode", "reconnect_zigbee_router"]) @@ -90,6 +94,29 @@ async def test_disabled_by_default_buttons( assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_zigbee2_router_button( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test creation of second radio router button (if available).""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("button.mock_title_reconnect_zigbee_router") + assert state is not None + assert state.state == STATE_UNKNOWN + + entry = entity_registry.async_get("button.mock_title_reconnect_zigbee_router") + assert entry is not None + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-reconnect_zigbee_router_1" + + async def test_remove_router_reconnect( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/smlight/test_diagnostics.py b/tests/components/smlight/test_diagnostics.py index d0c756bfd87..778ef8e5811 100644 --- a/tests/components/smlight/test_diagnostics.py +++ b/tests/components/smlight/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.smlight.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/sms/test_init.py b/tests/components/sms/test_init.py new file mode 100644 index 00000000000..03cebfe9b52 --- /dev/null +++ b/tests/components/sms/test_init.py @@ -0,0 +1,59 @@ +"""Test init.""" + +from unittest.mock import Mock, patch + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +@patch.dict( + "sys.modules", + { + "gammu": Mock(), + "gammu.asyncworker": Mock(), + }, +) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + from homeassistant.components.sms import ( # pylint: disable=import-outside-toplevel + DEPRECATED_ISSUE_ID, + DOMAIN as SMS_DOMAIN, + ) + + with ( + patch("homeassistant.components.sms.create_sms_gateway", autospec=True), + patch("homeassistant.components.sms.PLATFORMS", []), + ): + config_entry = MockConfigEntry( + title="test", + domain=SMS_DOMAIN, + data={ + CONF_DEVICE: "/dev/ttyUSB0", + }, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert ( + HOMEASSISTANT_DOMAIN, + DEPRECATED_ISSUE_ID, + ) in issue_registry.issues + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert ( + HOMEASSISTANT_DOMAIN, + DEPRECATED_ISSUE_ID, + ) not in issue_registry.issues diff --git a/tests/components/snips/test_init.py b/tests/components/snips/test_init.py index 82dbf1cd281..2be6d769f08 100644 --- a/tests/components/snips/test_init.py +++ b/tests/components/snips/test_init.py @@ -7,7 +7,8 @@ import pytest import voluptuous as vol from homeassistant.components import snips -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.intent import ServiceIntentHandler, async_register from homeassistant.setup import async_setup_component @@ -15,9 +16,13 @@ from tests.common import async_fire_mqtt_message, async_mock_intent, async_mock_ from tests.typing import MqttMockHAClient -async def test_snips_config(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: +async def test_snips_config( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + issue_registry: ir.IssueRegistry, +) -> None: """Test Snips Config.""" - result = await async_setup_component( + assert await async_setup_component( hass, "snips", { @@ -28,7 +33,10 @@ async def test_snips_config(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> } }, ) - assert result + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{snips.DOMAIN}", + ) in issue_registry.issues async def test_snips_no_mqtt( diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index c51f7627efc..8f0ee17df44 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -35,6 +35,7 @@ 'original_name': 'Consumption year', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_year', 'unique_id': 'ce5f5431554d101905d31797e1232da8_inverter_1_consumption_year', @@ -81,12 +82,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power', 'unique_id': 'ce5f5431554d101905d31797e1232da8_inverter_1_current_power', @@ -145,6 +150,7 @@ 'original_name': 'Consumption year', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_year', 'unique_id': 'ce5f5431554d101905d31797e1232da8_inverter_2_consumption_year', @@ -191,12 +197,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power', 'unique_id': 'ce5f5431554d101905d31797e1232da8_inverter_2_current_power', @@ -243,12 +253,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Alternator loss', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alternator_loss', 'unique_id': 'ce5f5431554d101905d31797e1232da8_alternator_loss', @@ -304,6 +318,7 @@ 'original_name': 'Capacity', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'capacity', 'unique_id': 'ce5f5431554d101905d31797e1232da8_capacity', @@ -350,12 +365,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Consumption AC', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_ac', 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_ac', @@ -414,6 +433,7 @@ 'original_name': 'Consumption day', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_day', 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_day', @@ -472,6 +492,7 @@ 'original_name': 'Consumption month', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_month', 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_month', @@ -530,6 +551,7 @@ 'original_name': 'Consumption total', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_total', 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_total', @@ -588,6 +610,7 @@ 'original_name': 'Consumption year', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_year', 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_year', @@ -644,6 +667,7 @@ 'original_name': 'Consumption yesterday', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_yesterday', 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_yesterday', @@ -698,6 +722,7 @@ 'original_name': 'Efficiency', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'efficiency', 'unique_id': 'ce5f5431554d101905d31797e1232da8_efficiency', @@ -744,12 +769,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Installed peak power', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_power', 'unique_id': 'ce5f5431554d101905d31797e1232da8_total_power', @@ -800,6 +829,7 @@ 'original_name': 'Last update', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_update', 'unique_id': 'ce5f5431554d101905d31797e1232da8_last_updated', @@ -844,12 +874,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power AC', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_ac', 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_ac', @@ -896,12 +930,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power available', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_available', 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_available', @@ -948,12 +986,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power DC', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_dc', 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_dc', @@ -1000,12 +1042,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Self-consumption year', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'self_consumption_year', 'unique_id': 'ce5f5431554d101905d31797e1232da8_self_consumption_year', @@ -1061,6 +1107,7 @@ 'original_name': 'Usage', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'usage', 'unique_id': 'ce5f5431554d101905d31797e1232da8_usage', @@ -1107,12 +1154,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage AC', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac', 'unique_id': 'ce5f5431554d101905d31797e1232da8_voltage_ac', @@ -1159,12 +1210,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage DC', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_dc', 'unique_id': 'ce5f5431554d101905d31797e1232da8_voltage_dc', @@ -1223,6 +1278,7 @@ 'original_name': 'Yield day', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yield_day', 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_day', @@ -1281,6 +1337,7 @@ 'original_name': 'Yield month', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yield_month', 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_month', @@ -1339,6 +1396,7 @@ 'original_name': 'Yield total', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yield_total', 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_total', @@ -1385,6 +1443,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1394,6 +1455,7 @@ 'original_name': 'Yield year', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yield_year', 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_year', @@ -1413,7 +1475,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.0230', + 'state': '1.023', }) # --- # name: test_all_entities[sensor.solarlog_yield_yesterday-entry] @@ -1450,6 +1512,7 @@ 'original_name': 'Yield yesterday', 'platform': 'solarlog', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yield_yesterday', 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_yesterday', diff --git a/tests/components/solarlog/test_diagnostics.py b/tests/components/solarlog/test_diagnostics.py index bc0b020462d..b129f5265be 100644 --- a/tests/components/solarlog/test_diagnostics.py +++ b/tests/components/solarlog/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.const import Platform diff --git a/tests/components/solarlog/test_sensor.py b/tests/components/solarlog/test_sensor.py index 77aa0308cda..132220c6261 100644 --- a/tests/components/solarlog/test_sensor.py +++ b/tests/components/solarlog/test_sensor.py @@ -10,7 +10,7 @@ from solarlog_cli.solarlog_exceptions import ( SolarLogUpdateError, ) from solarlog_cli.solarlog_models import InverterData -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index e22f18c6d77..5043c9331fc 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -21,6 +21,7 @@ from soco.events_base import Event as SonosEvent from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN +from homeassistant.components.sonos.const import SONOS_SHARE from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo @@ -501,11 +502,50 @@ def mock_browse_by_idstring( return list_from_json_fixture("music_library_tracks.json") if search_type == "albums" and idstring == "A:ALBUM": return list_from_json_fixture("music_library_albums.json") + if search_type == SONOS_SHARE and idstring == "S:": + return [ + MockMusicServiceItem( + None, + "S://192.168.1.1/music", + "S:", + "object.container", + ) + ] + if search_type == SONOS_SHARE and idstring == "S://192.168.1.1/music": + return [ + MockMusicServiceItem( + None, + "S://192.168.1.1/music/beatles", + "S://192.168.1.1/music", + "object.container", + ), + MockMusicServiceItem( + None, + "S://192.168.1.1/music/elton%20john", + "S://192.168.1.1/music", + "object.container", + ), + ] + if search_type == SONOS_SHARE and idstring == "S://192.168.1.1/music/elton%20john": + return [ + MockMusicServiceItem( + None, + "S://192.168.1.1/music/elton%20john/Greatest%20Hits", + "S://192.168.1.1/music/elton%20john", + "object.container", + ), + MockMusicServiceItem( + None, + "S://192.168.1.1/music/elton%20john/Good%20Bye%20Yellow%20Brick%20Road", + "S://192.168.1.1/music/elton%20john", + "object.container", + ), + ] return [] def mock_get_music_library_information( - search_type: str, search_term: str, full_album_art_uri: bool = True + search_type: str, search_term: str | None = None, full_album_art_uri: bool = True ) -> list[MockMusicServiceItem]: """Mock the call to get music library information.""" if search_type == "albums" and search_term == "Abbey Road": @@ -517,6 +557,10 @@ def mock_get_music_library_information( "object.container.album.musicAlbum", ) ] + if search_type == "sonos_playlists": + playlists = load_json_value_fixture("sonos_playlists.json", "sonos") + playlists_list = [DidlPlaylistContainer.from_dict(pl) for pl in playlists] + return SearchResult(playlists_list, "sonos_playlists", 1, 1, 0) return [] diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index faa06a9adc2..ddf03ca3b37 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -16,6 +16,17 @@ 'thumbnail': None, 'title': 'Albums', }), + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'playlist', + 'media_content_id': 'object.container.playlistContainer', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Playlists', + }), dict({ 'can_expand': True, 'can_play': False, @@ -181,6 +192,17 @@ 'thumbnail': None, 'title': 'Playlists', }), + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'S:', + 'media_content_type': 'directory', + 'thumbnail': None, + 'title': 'Folders', + }), ]) # --- # name: test_browse_media_library_albums @@ -231,6 +253,71 @@ }), ]) # --- +# name: test_browse_media_library_folders[S://192.168.1.1/music] + dict({ + 'can_expand': False, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'S://192.168.1.1/music/beatles', + 'media_content_type': 'directory', + 'thumbnail': None, + 'title': 'beatles', + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'S://192.168.1.1/music/elton%20john', + 'media_content_type': 'directory', + 'thumbnail': None, + 'title': 'elton john', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'S://192.168.1.1/music', + 'media_content_type': 'directory', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'music', + }) +# --- +# name: test_browse_media_library_folders[S:] + dict({ + 'can_expand': False, + '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': 'S://192.168.1.1/music', + 'media_content_type': 'directory', + 'thumbnail': None, + 'title': 'music', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'S:', + 'media_content_type': 'directory', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Folders', + }) +# --- # name: test_browse_media_root list([ dict({ diff --git a/tests/components/sonos/snapshots/test_media_player.ambr b/tests/components/sonos/snapshots/test_media_player.ambr index 7f4681d8915..66b322ea776 100644 --- a/tests/components/sonos/snapshots/test_media_player.ambr +++ b/tests/components/sonos/snapshots/test_media_player.ambr @@ -28,6 +28,7 @@ 'original_name': None, 'platform': 'sonos', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'RINCON_test', diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index ce6e103be58..3be0767ca99 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -3,13 +3,21 @@ from functools import partial import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + BrowseMedia, + MediaClass, + MediaType, +) +from homeassistant.components.sonos.const import MEDIA_TYPE_DIRECTORY from homeassistant.components.sonos.media_browser import ( build_item_response, get_thumbnail_url_full, ) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from .conftest import SoCoMockFactory @@ -217,3 +225,37 @@ async def test_browse_media_favorites( response = await client.receive_json() assert response["success"] assert response["result"] == snapshot + + +@pytest.mark.parametrize( + "media_content_id", + [ + ("S:"), + ("S://192.168.1.1/music"), + ], +) +async def test_browse_media_library_folders( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + media_content_id: str, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_ID: media_content_id, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_DIRECTORY, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + assert soco_mock.music_library.browse_by_idstring.call_count == 1 diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 78d88a1ea98..37ce119b0de 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from soco.data_structures import SearchResult from sonos_websocket.exception import SonosWebsocketError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, @@ -28,6 +28,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.sonos.const import ( DOMAIN as SONOS_DOMAIN, + MEDIA_TYPE_DIRECTORY, SOURCE_LINEIN, SOURCE_TV, ) @@ -182,6 +183,19 @@ async def test_entity_basic( "play_pos": 0, }, ), + ( + MEDIA_TYPE_DIRECTORY, + "S://192.168.1.1/music/elton%20john", + MediaPlayerEnqueue.REPLACE, + { + "title": None, + "item_id": "S://192.168.1.1/music/elton%20john", + "clear_queue": 1, + "position": None, + "play": 1, + "play_pos": 0, + }, + ), ], ) async def test_play_media_library( @@ -247,6 +261,11 @@ async def test_play_media_library( "A:ALBUM/UnknowAlbum", "Sonos does not support media content type: UnknownContent", ), + ( + MEDIA_TYPE_DIRECTORY, + "S://192.168.1.1/music/error", + "Could not find media in library: S://192.168.1.1/music/error", + ), ], ) async def test_play_media_library_content_error( diff --git a/tests/components/spotify/snapshots/test_media_player.ambr b/tests/components/spotify/snapshots/test_media_player.ambr index 74dbcb50f92..c275446d999 100644 --- a/tests/components/spotify/snapshots/test_media_player.ambr +++ b/tests/components/spotify/snapshots/test_media_player.ambr @@ -31,6 +31,7 @@ 'original_name': None, 'platform': 'spotify', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'spotify', 'unique_id': '1112264111', @@ -101,6 +102,7 @@ 'original_name': None, 'platform': 'spotify', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'spotify', 'unique_id': '1112264111', diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index 24c0e1d41d9..31842253c0c 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -1,33 +1,21 @@ """Tests for the Spotify config flow.""" from http import HTTPStatus -from ipaddress import ip_address from unittest.mock import MagicMock, patch import pytest from spotifyaio import SpotifyConnectionError from homeassistant.components.spotify.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -BLANK_ZEROCONF_INFO = ZeroconfServiceInfo( - ip_address=ip_address("1.2.3.4"), - ip_addresses=[ip_address("1.2.3.4")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={}, - type="mock_type", -) - async def test_abort_if_no_configuration(hass: HomeAssistant) -> None: """Check flow aborts when no configuration is present.""" @@ -38,25 +26,6 @@ async def test_abort_if_no_configuration(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "missing_credentials" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_ZEROCONF}, data=BLANK_ZEROCONF_INFO - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "missing_credentials" - - -async def test_zeroconf_abort_if_existing_entry(hass: HomeAssistant) -> None: - """Check zeroconf flow aborts when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_ZEROCONF}, data=BLANK_ZEROCONF_INFO - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("setup_credentials") diff --git a/tests/components/spotify/test_diagnostics.py b/tests/components/spotify/test_diagnostics.py index 6744ca11a00..80ef136e779 100644 --- a/tests/components/spotify/test_diagnostics.py +++ b/tests/components/spotify/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/spotify/test_media_browser.py b/tests/components/spotify/test_media_browser.py index ff3404dcfe9..603bc70c7c5 100644 --- a/tests/components/spotify/test_media_browser.py +++ b/tests/components/spotify/test_media_browser.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import BrowseError from homeassistant.components.spotify import DOMAIN diff --git a/tests/components/spotify/test_media_player.py b/tests/components/spotify/test_media_player.py index 456af43d411..913034b9636 100644 --- a/tests/components/spotify/test_media_player.py +++ b/tests/components/spotify/test_media_player.py @@ -12,7 +12,7 @@ from spotifyaio import ( SpotifyConnectionError, SpotifyNotFoundError, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 769e611bf28..0108dacb00a 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -25,12 +25,13 @@ from homeassistant.components.squeezebox.const import ( STATUS_SENSOR_OTHER_PLAYER_COUNT, STATUS_SENSOR_PLAYER_COUNT, STATUS_SENSOR_RESCAN, + STATUS_UPDATE_NEWPLUGINS, + STATUS_UPDATE_NEWVERSION, ) from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac -# from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry CONF_VOLUME_STEP = "volume_step" @@ -46,6 +47,7 @@ SERVER_UUIDS = [ TEST_MAC = ["aa:bb:cc:dd:ee:ff", "ff:ee:dd:cc:bb:aa"] TEST_PLAYER_NAME = "Test Player" TEST_SERVER_NAME = "Test Server" +TEST_ALARM_ID = "1" FAKE_VALID_ITEM_ID = "1234" FAKE_INVALID_ITEM_ID = "4321" @@ -69,6 +71,9 @@ FAKE_QUERY_RESPONSE = { STATUS_SENSOR_INFO_TOTAL_SONGS: 42, STATUS_SENSOR_PLAYER_COUNT: 10, STATUS_SENSOR_OTHER_PLAYER_COUNT: 0, + STATUS_UPDATE_NEWVERSION: 'A new version of Logitech Media Server is available (8.5.2 - 0). Click here for further information.', + STATUS_UPDATE_NEWPLUGINS: "Plugins have been updated - Restart Required (Big Sounds)", + "_can": 1, "players_loop": [ { "isplaying": 0, @@ -126,11 +131,15 @@ async def mock_async_play_announcement(media_id: str) -> bool: async def mock_async_browse( - media_type: MediaType, limit: int, browse_id: tuple | None = None + media_type: MediaType, + limit: int, + browse_id: tuple | None = None, + search_query: str | None = None, ) -> dict | None: """Mock the async_browse method of pysqueezebox.Player.""" child_types = { "favorites": "favorites", + "favorite": "favorite", "new music": "album", "album artists": "artists", "albums": "album", @@ -219,6 +228,21 @@ async def mock_async_browse( "items": fake_items, } return None + + if search_query: + if search_query not in [x["title"] for x in fake_items]: + return None + + for item in fake_items: + if ( + item["title"] == search_query + and item["item_type"] == child_types[media_type] + ): + return { + "title": media_type, + "items": [item], + } + if ( media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values() or media_type == "app-fakecommand" @@ -270,6 +294,7 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.image_url = None mock_player.model = "SqueezeLite" mock_player.creator = "Ralph Irving & Adrian Smith" + mock_player.alarms_enabled = True return mock_player @@ -299,7 +324,10 @@ def mock_pysqueezebox_server( mock_lms.uuid = uuid mock_lms.name = TEST_SERVER_NAME mock_lms.async_query = AsyncMock(return_value={"uuid": format_mac(uuid)}) - mock_lms.async_status = AsyncMock(return_value={"uuid": format_mac(uuid)}) + mock_lms.async_status = AsyncMock( + return_value={"uuid": format_mac(uuid), "version": FAKE_VERSION} + ) + mock_lms.async_prepared_status = mock_lms.async_status return mock_lms @@ -337,6 +365,47 @@ async def configure_squeezebox_media_player_button_platform( await hass.async_block_till_done(wait_background_tasks=True) +async def configure_squeezebox_switch_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + lms: MagicMock, +) -> None: + """Configure a squeezebox config entry with appropriate mocks for switch.""" + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.SWITCH], + ), + patch("homeassistant.components.squeezebox.Server", return_value=lms), + ): + # Set up the switch platform. + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.fixture +async def mock_alarms_player( + hass: HomeAssistant, + config_entry: MockConfigEntry, + lms: MagicMock, +) -> MagicMock: + """Mock the alarms of a configured player.""" + players = await lms.async_get_players() + players[0].alarms = [ + { + "id": TEST_ALARM_ID, + "enabled": True, + "time": "07:00", + "dow": [0, 1, 2, 3, 4, 5, 6], + "repeat": False, + "url": "CURRENT_PLAYLIST", + "volume": 50, + }, + ] + await configure_squeezebox_switch_platform(hass, config_entry, lms) + return players[0] + + @pytest.fixture async def configured_player( hass: HomeAssistant, config_entry: MockConfigEntry, lms: MagicMock diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index 7540a448882..4bb00dea5c6 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -65,7 +65,8 @@ 'original_name': None, 'platform': 'squeezebox', 'previous_unique_id': None, - 'supported_features': , + 'suggested_object_id': None, + 'supported_features': , 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff', 'unit_of_measurement': None, @@ -84,7 +85,7 @@ }), 'repeat': , 'shuffle': False, - 'supported_features': , + 'supported_features': , 'volume_level': 0.01, }), 'context': , diff --git a/tests/components/squeezebox/snapshots/test_switch.ambr b/tests/components/squeezebox/snapshots/test_switch.ambr new file mode 100644 index 00000000000..275fc26baa7 --- /dev/null +++ b/tests/components/squeezebox/snapshots/test_switch.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_entity_registry[switch.test_player_alarm_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_player_alarm_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': 'Alarm (1)', + 'platform': 'squeezebox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': 'aa:bb:cc:dd:ee:ff_alarm_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[switch.test_player_alarm_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'alarm_id': '1', + 'friendly_name': 'Test Player Alarm (1)', + }), + 'context': , + 'entity_id': 'switch.test_player_alarm_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[switch.test_player_alarms_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_player_alarms_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': 'Alarms enabled', + 'platform': 'squeezebox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_enabled', + 'unique_id': 'aa:bb:cc:dd:ee:ff_alarms_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[switch.test_player_alarms_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Player Alarms enabled', + }), + 'context': , + 'entity_id': 'switch.test_player_alarms_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/squeezebox/test_init.py b/tests/components/squeezebox/test_init.py index 9074f57cdcb..f70782b13da 100644 --- a/tests/components/squeezebox/test_init.py +++ b/tests/components/squeezebox/test_init.py @@ -1,7 +1,9 @@ """Test squeezebox initialization.""" +from http import HTTPStatus from unittest.mock import patch +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -21,3 +23,62 @@ async def test_init_api_fail( ), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) + + +async def test_init_timeout_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test init fail due to TimeoutError.""" + + # Setup component to raise TimeoutError + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + side_effect=TimeoutError, + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_init_unauthorized( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test init fail due to unauthorized error.""" + + # Setup component to simulate unauthorized response + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=False, # async_query returns False on auth failure + ), + patch( + "homeassistant.components.squeezebox.Server", # Patch the Server class itself + autospec=True, + ) as mock_server_instance, + ): + mock_server_instance.return_value.http_status = HTTPStatus.UNAUTHORIZED + assert not await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_init_missing_uuid( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test init fail due to missing UUID in server status.""" + # A response that is truthy but does not contain STATUS_QUERY_UUID + mock_status_without_uuid = {"name": "Test Server"} + + with patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=mock_status_without_uuid, + ) as mock_async_query: + # ConfigEntryError is raised, caught by setup, and returns False + assert not await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + mock_async_query.assert_called_once_with( + "serverstatus", "-", "-", "prefs:libraryname" + ) diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index f1ba187a699..093e4f186d4 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -10,6 +10,7 @@ from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, BrowseError, + MediaClass, MediaType, ) from homeassistant.components.squeezebox.browse_media import ( @@ -170,6 +171,129 @@ async def test_async_browse_media_for_apps( assert "Fake Invalid Item 1" not in search +@pytest.mark.parametrize( + ("category", "media_filter_classes"), + [ + ("favorites", None), + ("artists", None), + ("albums", None), + ("playlists", None), + ("genres", None), + ("new music", None), + ("album artists", None), + ("albums", [MediaClass.ALBUM]), + ], +) +async def test_async_search_media( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + category: str, + media_filter_classes: list[MediaClass] | None, +) -> None: + """Test each category with subitems.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + "search_query": "Fake Item 1", + "media_filter_classes": media_filter_classes, + } + ) + response = await client.receive_json() + assert response["success"] + category_level = response["result"]["result"] + assert category_level[0]["title"] == "Fake Item 1" + + +async def test_async_search_media_invalid_filter( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test search_media action with invalid media_filter_class.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "albums", + "search_query": "Fake Item 1", + "media_filter_classes": "movie", + } + ) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["result"]) == 0 + + +async def test_async_search_media_invalid_type( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test search_media action with invalid media_content_type.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "Fake Type", + "search_query": "Fake Item 1", + }, + ) + response = await client.receive_json() + assert not response["success"] + err_message = "If specified, Media content type must be one of" + assert err_message in response["error"]["message"] + + +async def test_async_search_media_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test trying to play an item that doesn't exist.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "", + "search_query": "Unknown Item", + }, + ) + response = await client.receive_json() + + assert len(response["result"]["result"]) == 0 + + async def test_generate_playlist_for_app( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index b69f6cc9240..1890cde5293 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, diff --git a/tests/components/squeezebox/test_switch.py b/tests/components/squeezebox/test_switch.py new file mode 100644 index 00000000000..e4c8c3b5e4d --- /dev/null +++ b/tests/components/squeezebox/test_switch.py @@ -0,0 +1,135 @@ +"""Tests for the Squeezebox alarm switch platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.squeezebox.const import PLAYER_UPDATE_INTERVAL +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from .conftest import TEST_ALARM_ID + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_alarms_player: MagicMock, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, +) -> None: + """Test squeezebox media_player entity registered in the entity registry.""" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_switch_state( + hass: HomeAssistant, + mock_alarms_player: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the state of the switch.""" + assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}").state == "on" + + mock_alarms_player.alarms[0]["enabled"] = False + freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}").state == "off" + + +async def test_switch_deleted( + hass: HomeAssistant, + mock_alarms_player: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test detecting switch deleted.""" + assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}").state == "on" + + mock_alarms_player.alarms = [] + freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}") is None + + +async def test_turn_on( + hass: HomeAssistant, + mock_alarms_player: MagicMock, +) -> None: + """Test turning on the switch.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: f"switch.test_player_alarm_{TEST_ALARM_ID}"}, + blocking=True, + ) + mock_alarms_player.async_update_alarm.assert_called_once_with( + TEST_ALARM_ID, enabled=True + ) + + +async def test_turn_off( + hass: HomeAssistant, + mock_alarms_player: MagicMock, +) -> None: + """Test turning on the switch.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: f"switch.test_player_alarm_{TEST_ALARM_ID}"}, + blocking=True, + ) + mock_alarms_player.async_update_alarm.assert_called_once_with( + TEST_ALARM_ID, enabled=False + ) + + +async def test_alarms_enabled_state( + hass: HomeAssistant, + mock_alarms_player: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the alarms enabled switch.""" + + assert hass.states.get("switch.test_player_alarms_enabled").state == "on" + + mock_alarms_player.alarms_enabled = False + freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("switch.test_player_alarms_enabled").state == "off" + + +async def test_alarms_enabled_turn_on( + hass: HomeAssistant, + mock_alarms_player: MagicMock, +) -> None: + """Test turning on the alarms enabled switch.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.test_player_alarms_enabled"}, + blocking=True, + ) + mock_alarms_player.async_set_alarms_enabled.assert_called_once_with(True) + + +async def test_alarms_enabled_turn_off( + hass: HomeAssistant, + mock_alarms_player: MagicMock, +) -> None: + """Test turning off the alarms enabled switch.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.test_player_alarms_enabled"}, + blocking=True, + ) + mock_alarms_player.async_set_alarms_enabled.assert_called_once_with(False) diff --git a/tests/components/squeezebox/test_update.py b/tests/components/squeezebox/test_update.py new file mode 100644 index 00000000000..b233afbcde1 --- /dev/null +++ b/tests/components/squeezebox/test_update.py @@ -0,0 +1,232 @@ +"""Test squeezebox update platform.""" + +import copy +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.squeezebox.const import ( + SENSOR_UPDATE_INTERVAL, + STATUS_UPDATE_NEWPLUGINS, +) +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util + +from .conftest import FAKE_QUERY_RESPONSE + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_update_lms( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("update.fakelib_lyrion_music_server") + + assert state is not None + assert state.state == STATE_ON + + +async def test_update_plugins_install_fallback( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + entity_id = "update.fakelib_updated_plugins" + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + polltime = 30 + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=False, + ), + patch( + "homeassistant.components.squeezebox.update.POLL_AFTER_INSTALL", + polltime, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + attrs = state.attributes + assert attrs[ATTR_IN_PROGRESS] + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_status", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=polltime + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + attrs = state.attributes + assert not attrs[ATTR_IN_PROGRESS] + + +async def test_update_plugins_install_restart_fail( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + entity_id = "update.fakelib_updated_plugins" + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=True, + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + attrs = state.attributes + assert not attrs[ATTR_IN_PROGRESS] + + +async def test_update_plugins_install_ok( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + entity_id = "update.fakelib_updated_plugins" + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=False, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + attrs = state.attributes + assert attrs[ATTR_IN_PROGRESS] + + resp = copy.deepcopy(FAKE_QUERY_RESPONSE) + del resp[STATUS_UPDATE_NEWPLUGINS] + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_status", + return_value=resp, + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=SENSOR_UPDATE_INTERVAL + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + attrs = state.attributes + assert not attrs[ATTR_IN_PROGRESS] diff --git a/tests/components/statistics/test_config_flow.py b/tests/components/statistics/test_config_flow.py index 77ccba5ba4c..fd82e688ee0 100644 --- a/tests/components/statistics/test_config_flow.py +++ b/tests/components/statistics/test_config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.recorder import Recorder diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index 64829ea7d66..c11045a2eb2 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -2,14 +2,98 @@ from __future__ import annotations -from homeassistant.components.statistics import DOMAIN as STATISTICS_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from unittest.mock import patch + +import pytest + +from homeassistant.components import statistics +from homeassistant.components.statistics import DOMAIN +from homeassistant.components.statistics.config_flow import StatisticsConfigFlowHandler +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def statistics_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a statistics config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": sensor_entity_entry.entity_id, + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=StatisticsConfigFlowHandler.VERSION, + minor_version=StatisticsConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: """Test unload an entry.""" @@ -51,7 +135,7 @@ async def test_device_cleaning( # Configure the configuration entry for Statistics statistics_config_entry = MockConfigEntry( data={}, - domain=STATISTICS_DOMAIN, + domain=DOMAIN, options={ "name": "Statistics", "entity_id": "sensor.test_source", @@ -107,3 +191,194 @@ async def test_device_cleaning( assert len(devices_after_reload) == 1 assert devices_after_reload[0].id == source_device1_entry.id + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the statistics config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the statistics config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + + # Check that the statistics config entry is removed + assert statistics_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the statistics config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + + # Check that the statistics config entry is not removed + assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert statistics_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the statistics config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert statistics_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the statistics config entry is not removed + assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the statistics config entry is updated with the new entity ID + assert statistics_config_entry.options["entity_id"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + # Check that the statistics config entry is not removed + assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] diff --git a/tests/components/steamist/test_sensor.py b/tests/components/steamist/test_sensor.py index 79592f9fc85..8731e803e0b 100644 --- a/tests/components/steamist/test_sensor.py +++ b/tests/components/steamist/test_sensor.py @@ -16,7 +16,7 @@ async def test_steam_active(hass: HomeAssistant) -> None: """Test that the sensors are setup with the expected values when steam is active.""" await _async_setup_entry_with_status(hass, MOCK_ASYNC_GET_STATUS_ACTIVE) state = hass.states.get("sensor.steam_temperature") - assert state.state == "39" + assert round(float(state.state)) == 39 assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS state = hass.states.get("sensor.steam_minutes_remain") assert state.state == "14" @@ -27,7 +27,7 @@ async def test_steam_inactive(hass: HomeAssistant) -> None: """Test that the sensors are setup with the expected values when steam is not active.""" await _async_setup_entry_with_status(hass, MOCK_ASYNC_GET_STATUS_INACTIVE) state = hass.states.get("sensor.steam_temperature") - assert state.state == "21" + assert round(float(state.state)) == 21 assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS state = hass.states.get("sensor.steam_minutes_remain") assert state.state == "0" diff --git a/tests/components/stookwijzer/snapshots/test_sensor.ambr b/tests/components/stookwijzer/snapshots/test_sensor.ambr index ff1f6a12b8a..e0e3de207d0 100644 --- a/tests/components/stookwijzer/snapshots/test_sensor.ambr +++ b/tests/components/stookwijzer/snapshots/test_sensor.ambr @@ -33,6 +33,7 @@ 'original_name': 'Advice code', 'platform': 'stookwijzer', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'advice', 'unique_id': '12345_advice', @@ -89,6 +90,7 @@ 'original_name': 'Air quality index', 'platform': 'stookwijzer', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345_air_quality_index', @@ -147,6 +149,7 @@ 'original_name': 'Wind speed', 'platform': 'stookwijzer', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345_windspeed', diff --git a/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr index d13a19bc656..38cbef26f6a 100644 --- a/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr +++ b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Away mode', 'platform': 'streamlabswater', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'away_mode', 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-away_mode', diff --git a/tests/components/streamlabswater/snapshots/test_sensor.ambr b/tests/components/streamlabswater/snapshots/test_sensor.ambr index c1248f2c0a0..404e636bd3e 100644 --- a/tests/components/streamlabswater/snapshots/test_sensor.ambr +++ b/tests/components/streamlabswater/snapshots/test_sensor.ambr @@ -30,6 +30,7 @@ 'original_name': 'Daily usage', 'platform': 'streamlabswater', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_usage', 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-daily_usage', @@ -82,6 +83,7 @@ 'original_name': 'Monthly usage', 'platform': 'streamlabswater', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'monthly_usage', 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-monthly_usage', @@ -134,6 +136,7 @@ 'original_name': 'Yearly usage', 'platform': 'streamlabswater', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'yearly_usage', 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-yearly_usage', diff --git a/tests/components/streamlabswater/test_binary_sensor.py b/tests/components/streamlabswater/test_binary_sensor.py index 7beb088d498..e9f899409a2 100644 --- a/tests/components/streamlabswater/test_binary_sensor.py +++ b/tests/components/streamlabswater/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/streamlabswater/test_sensor.py b/tests/components/streamlabswater/test_sensor.py index 6afb71f3fd7..ddae5ba3a9f 100644 --- a/tests/components/streamlabswater/test_sensor.py +++ b/tests/components/streamlabswater/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index 0e15dead33f..c2cebc01c96 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -153,21 +153,21 @@ EXPECTED_STATE_EV_IMPERIAL = { EXPECTED_STATE_EV_METRIC = { "AVG_FUEL_CONSUMPTION": "4.6", - "DISTANCE_TO_EMPTY_FUEL": "274", + "DISTANCE_TO_EMPTY_FUEL": "273.59", "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": "2", + "EV_DISTANCE_TO_EMPTY": "1.61", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", - "ODOMETER": "1986", + "ODOMETER": "1985.93", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": "0.0", - "TYRE_PRESSURE_FRONT_RIGHT": "219.9", - "TYRE_PRESSURE_REAR_LEFT": "224.8", + "TYRE_PRESSURE_FRONT_LEFT": "0.00", + "TYRE_PRESSURE_FRONT_RIGHT": "219.94", + "TYRE_PRESSURE_REAR_LEFT": "224.77", "TYRE_PRESSURE_REAR_RIGHT": "unknown", "VEHICLE_STATE_TYPE": "IGNITION_OFF", "LATITUDE": 40.0, diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py index a468a2442e1..c8812460e68 100644 --- a/tests/components/subaru/test_sensor.py +++ b/tests/components/subaru/test_sensor.py @@ -27,6 +27,8 @@ from .conftest import ( setup_subaru_config_entry, ) +from tests.common import get_sensor_display_state + async def test_sensors_ev_metric(hass: HomeAssistant, ev_entry) -> None: """Test sensors supporting metric units.""" @@ -141,5 +143,5 @@ def _assert_data(hass: HomeAssistant, expected_state: dict[str, Any]) -> None: expected_states[entity] = expected_state[item.key] for sensor, value in expected_states.items(): - actual = hass.states.get(sensor) - assert actual.state == value + state = get_sensor_display_state(hass, entity_registry, sensor) + assert state == value diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index 73557fd3bde..9d29191289e 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -8,17 +8,26 @@ from pysuez import AggregatedData, PriceResult from pysuez.const import ATTRIBUTION import pytest +from homeassistant.components.recorder import Recorder from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN from tests.common import MockConfigEntry +from tests.conftest import RecorderInstanceContextManager MOCK_DATA = { "username": "test-username", "password": "test-password", - CONF_COUNTER_ID: "test-counter", + CONF_COUNTER_ID: "123456", } +@pytest.fixture +async def mock_recorder_before_hass( + async_test_recorder: RecorderInstanceContextManager, +) -> None: + """Set up recorder.""" + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Create mock config_entry needed by suez_water integration.""" @@ -32,7 +41,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: +def mock_setup_entry(recorder_mock: Recorder) -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.suez_water.async_setup_entry", return_value=True @@ -41,7 +50,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="suez_client") -def mock_suez_client() -> Generator[AsyncMock]: +def mock_suez_client(recorder_mock: Recorder) -> Generator[AsyncMock]: """Create mock for suez_water external api.""" with ( patch( diff --git a/tests/components/suez_water/snapshots/test_init.ambr b/tests/components/suez_water/snapshots/test_init.ambr new file mode 100644 index 00000000000..24e11654cd0 --- /dev/null +++ b/tests/components/suez_water/snapshots/test_init.ambr @@ -0,0 +1,231 @@ +# serializer version: 1 +# name: test_statistics[water_consumption_statistics][test_statistics_call1] + defaultdict({ + 'suez_water:123456_water_consumption_statistics': list([ + dict({ + 'end': 1733043600.0, + 'last_reset': None, + 'start': 1733040000.0, + 'state': 500.0, + 'sum': 500.0, + }), + dict({ + 'end': 1733130000.0, + 'last_reset': None, + 'start': 1733126400.0, + 'state': 500.0, + 'sum': 1000.0, + }), + dict({ + 'end': 1733216400.0, + 'last_reset': None, + 'start': 1733212800.0, + 'state': 500.0, + 'sum': 1500.0, + }), + ]), + }) +# --- +# name: test_statistics[water_consumption_statistics][test_statistics_call2] + defaultdict({ + 'suez_water:123456_water_consumption_statistics': list([ + dict({ + 'end': 1733043600.0, + 'last_reset': None, + 'start': 1733040000.0, + 'state': 500.0, + 'sum': 500.0, + }), + dict({ + 'end': 1733130000.0, + 'last_reset': None, + 'start': 1733126400.0, + 'state': 500.0, + 'sum': 1000.0, + }), + dict({ + 'end': 1733216400.0, + 'last_reset': None, + 'start': 1733212800.0, + 'state': 500.0, + 'sum': 1500.0, + }), + ]), + }) +# --- +# name: test_statistics[water_consumption_statistics][test_statistics_call3] + defaultdict({ + 'suez_water:123456_water_consumption_statistics': list([ + dict({ + 'end': 1733043600.0, + 'last_reset': None, + 'start': 1733040000.0, + 'state': 500.0, + 'sum': 500.0, + }), + dict({ + 'end': 1733130000.0, + 'last_reset': None, + 'start': 1733126400.0, + 'state': 500.0, + 'sum': 1000.0, + }), + dict({ + 'end': 1733216400.0, + 'last_reset': None, + 'start': 1733212800.0, + 'state': 500.0, + 'sum': 1500.0, + }), + ]), + }) +# --- +# name: test_statistics[water_consumption_statistics][test_statistics_call4] + defaultdict({ + 'suez_water:123456_water_consumption_statistics': list([ + dict({ + 'end': 1733043600.0, + 'last_reset': None, + 'start': 1733040000.0, + 'state': 500.0, + 'sum': 500.0, + }), + dict({ + 'end': 1733130000.0, + 'last_reset': None, + 'start': 1733126400.0, + 'state': 500.0, + 'sum': 1000.0, + }), + dict({ + 'end': 1733216400.0, + 'last_reset': None, + 'start': 1733212800.0, + 'state': 500.0, + 'sum': 1500.0, + }), + dict({ + 'end': 1733389200.0, + 'last_reset': None, + 'start': 1733385600.0, + 'state': 500.0, + 'sum': 2000.0, + }), + ]), + }) +# --- +# name: test_statistics[water_cost_statistics][test_statistics_call1] + defaultdict({ + 'suez_water:123456_water_cost_statistics': list([ + dict({ + 'end': 1733043600.0, + 'last_reset': None, + 'start': 1733040000.0, + 'state': 2.37, + 'sum': 2.37, + }), + dict({ + 'end': 1733130000.0, + 'last_reset': None, + 'start': 1733126400.0, + 'state': 2.37, + 'sum': 4.74, + }), + dict({ + 'end': 1733216400.0, + 'last_reset': None, + 'start': 1733212800.0, + 'state': 2.37, + 'sum': 7.11, + }), + ]), + }) +# --- +# name: test_statistics[water_cost_statistics][test_statistics_call2] + defaultdict({ + 'suez_water:123456_water_cost_statistics': list([ + dict({ + 'end': 1733043600.0, + 'last_reset': None, + 'start': 1733040000.0, + 'state': 2.37, + 'sum': 2.37, + }), + dict({ + 'end': 1733130000.0, + 'last_reset': None, + 'start': 1733126400.0, + 'state': 2.37, + 'sum': 4.74, + }), + dict({ + 'end': 1733216400.0, + 'last_reset': None, + 'start': 1733212800.0, + 'state': 2.37, + 'sum': 7.11, + }), + ]), + }) +# --- +# name: test_statistics[water_cost_statistics][test_statistics_call3] + defaultdict({ + 'suez_water:123456_water_cost_statistics': list([ + dict({ + 'end': 1733043600.0, + 'last_reset': None, + 'start': 1733040000.0, + 'state': 2.37, + 'sum': 2.37, + }), + dict({ + 'end': 1733130000.0, + 'last_reset': None, + 'start': 1733126400.0, + 'state': 2.37, + 'sum': 4.74, + }), + dict({ + 'end': 1733216400.0, + 'last_reset': None, + 'start': 1733212800.0, + 'state': 2.37, + 'sum': 7.11, + }), + ]), + }) +# --- +# name: test_statistics[water_cost_statistics][test_statistics_call4] + defaultdict({ + 'suez_water:123456_water_cost_statistics': list([ + dict({ + 'end': 1733043600.0, + 'last_reset': None, + 'start': 1733040000.0, + 'state': 2.37, + 'sum': 2.37, + }), + dict({ + 'end': 1733130000.0, + 'last_reset': None, + 'start': 1733126400.0, + 'state': 2.37, + 'sum': 4.74, + }), + dict({ + 'end': 1733216400.0, + 'last_reset': None, + 'start': 1733212800.0, + 'state': 2.37, + 'sum': 7.11, + }), + dict({ + 'end': 1733389200.0, + 'last_reset': None, + 'start': 1733385600.0, + 'state': 2.37, + 'sum': 9.48, + }), + ]), + }) +# --- diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr index 536e79df606..ed05348d924 100644 --- a/tests/components/suez_water/snapshots/test_sensor.ambr +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -27,9 +27,10 @@ 'original_name': 'Water price', 'platform': 'suez_water', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_price', - 'unique_id': 'test-counter_water_price', + 'unique_id': '123456_water_price', 'unit_of_measurement': '€', }) # --- @@ -71,15 +72,19 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water usage yesterday', 'platform': 'suez_water', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_usage_yesterday', - 'unique_id': 'test-counter_water_usage_yesterday', + 'unique_id': '123456_water_usage_yesterday', 'unit_of_measurement': , }) # --- diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index bebb4fd72ac..656c804e4d9 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -6,6 +6,7 @@ from pysuez.exception import PySuezError import pytest from homeassistant import config_entries +from homeassistant.components.recorder import Recorder from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -70,7 +71,7 @@ async def test_form_invalid_auth( async def test_form_already_configured( - hass: HomeAssistant, suez_client: AsyncMock + hass: HomeAssistant, recorder_mock: Recorder, suez_client: AsyncMock ) -> None: """Test we abort when entry is already configured.""" diff --git a/tests/components/suez_water/test_init.py b/tests/components/suez_water/test_init.py index 16d32b61dee..ce010f50153 100644 --- a/tests/components/suez_water/test_init.py +++ b/tests/components/suez_water/test_init.py @@ -1,30 +1,32 @@ """Test Suez_water integration initialization.""" +from datetime import datetime, timedelta from unittest.mock import AsyncMock -from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN -from homeassistant.components.suez_water.coordinator import PySuezError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.recorder.statistics import statistics_during_period +from homeassistant.components.suez_water.const import ( + CONF_COUNTER_ID, + DATA_REFRESH_INTERVAL, + DOMAIN, +) +from homeassistant.components.suez_water.coordinator import ( + PySuezError, + TelemetryMeasure, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util from . import setup_integration from .conftest import MOCK_DATA -from tests.common import MockConfigEntry - - -async def test_initialization_invalid_credentials( - hass: HomeAssistant, - suez_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that suez_water can't be loaded with invalid credentials.""" - - suez_client.check_credentials.return_value = False - await setup_integration(hass, mock_config_entry) - - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.recorder.common import async_wait_recording_done async def test_initialization_setup_api_error( @@ -40,6 +42,210 @@ async def test_initialization_setup_api_error( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_init_auth_failed( + hass: HomeAssistant, + suez_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water reflect authentication failure.""" + suez_client.check_credentials.return_value = False + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_init_refresh_failed( + hass: HomeAssistant, + suez_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water reflect authentication failure.""" + suez_client.fetch_aggregated_data.side_effect = PySuezError("Update failed") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_init_statistics_failed( + hass: HomeAssistant, + suez_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water reflect authentication failure.""" + suez_client.fetch_all_daily_data.side_effect = PySuezError("Update failed") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("recorder_mock") +async def test_statistics_no_price( + hass: HomeAssistant, + suez_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water statistics does not register when no price.""" + # New data retrieved but no price + suez_client.get_price.side_effect = PySuezError("will fail") + suez_client.fetch_all_daily_data.return_value = [ + TelemetryMeasure( + (datetime.now().date()).strftime("%Y-%m-%d %H:%M:%S"), 0.5, 0.5 + ) + ] + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + statistic_id = ( + f"{DOMAIN}:{mock_config_entry.data[CONF_COUNTER_ID]}_water_cost_statistics" + ) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.now() - timedelta(days=1), + None, + [statistic_id], + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert stats.get(statistic_id) is None + + +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize( + "statistic", + [ + "water_cost_statistics", + "water_consumption_statistics", + ], +) +async def test_statistics( + hass: HomeAssistant, + suez_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + statistic: str, +) -> None: + """Test that suez_water statistics are working.""" + nb_samples = 3 + + start = datetime.fromisoformat("2024-12-04T02:00:00.0") + freezer.move_to(start) + + origin = dt_util.start_of_local_day(start.date()) - timedelta(days=nb_samples) + result = [ + TelemetryMeasure( + date=((origin + timedelta(days=d)).date()).strftime("%Y-%m-%d %H:%M:%S"), + volume=0.5, + index=0.5 * (d + 1), + ) + for d in range(nb_samples) + ] + suez_client.fetch_all_daily_data.return_value = result + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Init data retrieved + await _test_for_data( + hass, + suez_client, + snapshot, + statistic, + origin, + mock_config_entry.data[CONF_COUNTER_ID], + 1, + ) + + # No new data retrieved + suez_client.fetch_all_daily_data.return_value = [] + freezer.tick(DATA_REFRESH_INTERVAL) + async_fire_time_changed(hass) + + await _test_for_data( + hass, + suez_client, + snapshot, + statistic, + origin, + mock_config_entry.data[CONF_COUNTER_ID], + 2, + ) + # Old data retrieved + suez_client.fetch_all_daily_data.return_value = [ + TelemetryMeasure( + date=(origin.date() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"), + volume=0.5, + index=0.5 * (121 + 1), + ) + ] + freezer.tick(DATA_REFRESH_INTERVAL) + async_fire_time_changed(hass) + + await _test_for_data( + hass, + suez_client, + snapshot, + statistic, + origin, + mock_config_entry.data[CONF_COUNTER_ID], + 3, + ) + + # New daily data retrieved + suez_client.fetch_all_daily_data.return_value = [ + TelemetryMeasure( + date=(datetime.now().date()).strftime("%Y-%m-%d %H:%M:%S"), + volume=0.5, + index=0.5 * (121 + 1), + ) + ] + freezer.tick(DATA_REFRESH_INTERVAL) + async_fire_time_changed(hass) + + await _test_for_data( + hass, + suez_client, + snapshot, + statistic, + origin, + mock_config_entry.data[CONF_COUNTER_ID], + 4, + ) + + +async def _test_for_data( + hass: HomeAssistant, + suez_client: AsyncMock, + snapshot: SnapshotAssertion, + statistic: str, + origin: datetime, + counter_id: str, + nb_calls: int, +) -> None: + await hass.async_block_till_done(True) + await async_wait_recording_done(hass) + + assert suez_client.fetch_all_daily_data.call_count == nb_calls + statistic_id = f"{DOMAIN}:{counter_id}_{statistic}" + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + origin - timedelta(days=1), + None, + [statistic_id], + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + assert stats == snapshot(name=f"test_statistics_call{nb_calls}") + + async def test_migration_version_rollback( hass: HomeAssistant, suez_client: AsyncMock, diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py index 950d5d8393d..3ed0d8f0bed 100644 --- a/tests/components/suez_water/test_sensor.py +++ b/tests/components/suez_water/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.suez_water.const import DATA_REFRESH_INTERVAL from homeassistant.components.suez_water.coordinator import PySuezError @@ -41,16 +41,23 @@ async def test_sensors_valid_state( assert previous.get(str(date.fromisoformat("2024-12-01"))) == 154 -@pytest.mark.parametrize("method", [("fetch_aggregated_data"), ("get_price")]) +@pytest.mark.parametrize( + ("method", "price_on_error", "consumption_on_error"), + [ + ("fetch_aggregated_data", STATE_UNAVAILABLE, STATE_UNAVAILABLE), + ("get_price", STATE_UNAVAILABLE, "160"), + ], +) async def test_sensors_failed_update( hass: HomeAssistant, suez_client: AsyncMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, method: str, + price_on_error: str, + consumption_on_error: str, ) -> None: """Test that suez_water sensor reflect failure when api fails.""" - await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -58,10 +65,10 @@ async def test_sensors_failed_update( entity_ids = await hass.async_add_executor_job(hass.states.entity_ids) assert len(entity_ids) == 2 - for entity in entity_ids: - state = hass.states.get(entity) - assert entity - assert state.state != STATE_UNAVAILABLE + state = hass.states.get("sensor.suez_mock_device_water_price") + assert state.state == "4.74" + state = hass.states.get("sensor.suez_mock_device_water_usage_yesterday") + assert state.state == "160" getattr(suez_client, method).side_effect = PySuezError("Should fail to update") @@ -69,7 +76,7 @@ async def test_sensors_failed_update( async_fire_time_changed(hass) await hass.async_block_till_done(True) - for entity in entity_ids: - state = hass.states.get(entity) - assert entity - assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.suez_mock_device_water_price") + assert state.state == price_on_error + state = hass.states.get("sensor.suez_mock_device_water_usage_yesterday") + assert state.state == consumption_on_error diff --git a/tests/components/sun/test_condition.py b/tests/components/sun/test_condition.py new file mode 100644 index 00000000000..52c0d885461 --- /dev/null +++ b/tests/components/sun/test_condition.py @@ -0,0 +1,1235 @@ +"""The tests for sun conditions.""" + +from datetime import datetime + +from freezegun import freeze_time +import pytest + +from homeassistant.components import automation +from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import trace +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def prepare_condition_trace() -> None: + """Clear previous trace.""" + trace.trace_clear() + + +def _find_run_id(traces, trace_type, item_id): + """Find newest run_id for a script or automation.""" + for _trace in reversed(traces): + if _trace["domain"] == trace_type and _trace["item_id"] == item_id: + return _trace["run_id"] + + return None + + +async def assert_automation_condition_trace(hass_ws_client, automation_id, expected): + """Test the result of automation condition.""" + msg_id = 1 + + def next_id(): + nonlocal msg_id + msg_id += 1 + return msg_id + + client = await hass_ws_client() + + # List traces + await client.send_json( + {"id": next_id(), "type": "trace/list", "domain": "automation"} + ) + response = await client.receive_json() + assert response["success"] + run_id = _find_run_id(response["result"], "automation", automation_id) + + # Get trace + await client.send_json( + { + "id": next_id(), + "type": "trace/get", + "domain": "automation", + "item_id": "sun", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + trace = response["result"] + assert len(trace["trace"]["condition/0"]) == 1 + condition_trace = trace["trace"]["condition/0"][0]["result"] + assert condition_trace == expected + + +async def test_if_action_before_sunrise_no_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise. + + Before sunrise is true from midnight until sunset, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise + 1s -> 'before sunrise' not true + now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = sunrise -> 'before sunrise' true + now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = local midnight -> 'before sunrise' true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = local midnight - 1s -> 'before sunrise' not true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, + ) + + +async def test_if_action_after_sunrise_no_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise. + + After sunrise is true from sunrise until midnight, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise - 1s -> 'after sunrise' not true + now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = sunrise + 1s -> 'after sunrise' true + now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = local midnight -> 'after sunrise' not true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = local midnight - 1s -> 'after sunrise' true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, + ) + + +async def test_if_action_before_sunrise_with_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise with offset. + + Before sunrise is true from midnight until sunset, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "before": SUN_EVENT_SUNRISE, + "before_offset": "+1:00:00", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunrise + 1h -> 'before sunrise' with offset +1h true + now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = UTC midnight -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local midnight -> 'before sunrise' with offset +1h true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local midnight - 1s -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunset -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunset -1s -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + +async def test_if_action_before_sunset_with_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunset with offset. + + Before sunset is true from midnight until sunset, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "before": "sunset", + "before_offset": "+1:00:00", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = local midnight -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true + now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunset + 1h -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = UTC midnight -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = UTC midnight - 1s -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 4 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunrise -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 5 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunrise -1s -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 6 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = local midnight-1s -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 6 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + +async def test_if_action_after_sunrise_with_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise with offset. + + After sunrise is true from sunrise until midnight, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "after": SUN_EVENT_SUNRISE, + "after_offset": "+1:00:00", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunrise + 1h -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = UTC noon -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local noon -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local noon - 1s -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunset -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 4 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunset + 1s -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 5 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local midnight-1s -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 6 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local midnight -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 6 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-17T14:33:57.053037+00:00"}, + ) + + +async def test_if_action_after_sunset_with_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunset with offset. + + After sunset is true from sunset until midnight, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "after": "sunset", + "after_offset": "+1:00:00", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true + now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunset + 1h -> 'after sunset' with offset +1h true + now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = midnight-1s -> 'after sunset' with offset +1h true + now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T02:55:06.099767+00:00"}, + ) + + # now = midnight -> 'after sunset' with offset +1h not true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, + ) + + +async def test_if_action_after_and_before_during( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise and before sunset. + + This is true from sunrise until sunset. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "after": SUN_EVENT_SUNRISE, + "before": SUN_EVENT_SUNSET, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true + now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true + now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-17T01:53:44.723614+00:00"}, + ) + + # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true + now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset - 1s -> 'after sunrise' + 'before sunset' true + now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = 9AM local -> 'after sunrise' + 'before sunset' true + now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + +async def test_if_action_before_or_after_during( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise or after sunset. + + This is true from midnight until sunrise and from sunset until midnight + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "before": SUN_EVENT_SUNRISE, + "after": SUN_EVENT_SUNSET, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset + 1s -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false + now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset - 1s -> 'before sunrise' | 'after sunset' false + now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 4 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + +async def test_if_action_before_sunrise_no_offset_kotzebue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise. + + Local timezone: Alaska time + Location: Kotzebue, which has a very skewed local timezone with sunrise + at 7 AM and sunset at 3AM during summer + After sunrise is true from sunrise until midnight, local time. + """ + await hass.config.async_set_time_zone("America/Anchorage") + hass.config.latitude = 66.5 + hass.config.longitude = 162.4 + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local + # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC + # now = sunrise + 1s -> 'before sunrise' not true + now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = sunrise - 1h -> 'before sunrise' true + now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = local midnight -> 'before sunrise' true + now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = local midnight - 1s -> 'before sunrise' not true + now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-07-23T15:12:19.155123+00:00"}, + ) + + +async def test_if_action_after_sunrise_no_offset_kotzebue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise. + + Local timezone: Alaska time + Location: Kotzebue, which has a very skewed local timezone with sunrise + at 7 AM and sunset at 3AM during summer + Before sunrise is true from midnight until sunrise, local time. + """ + await hass.config.async_set_time_zone("America/Anchorage") + hass.config.latitude = 66.5 + hass.config.longitude = 162.4 + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local + # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC + # now = sunrise -> 'after sunrise' true + now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = sunrise - 1h -> 'after sunrise' not true + now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = local midnight -> 'after sunrise' not true + now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = local midnight - 1s -> 'after sunrise' true + now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-07-23T15:12:19.155123+00:00"}, + ) + + +async def test_if_action_before_sunset_no_offset_kotzebue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise. + + Local timezone: Alaska time + Location: Kotzebue, which has a very skewed local timezone with sunrise + at 7 AM and sunset at 3AM during summer + Before sunset is true from midnight until sunset, local time. + """ + await hass.config.async_set_time_zone("America/Anchorage") + hass.config.latitude = 66.5 + hass.config.longitude = 162.4 + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "before": SUN_EVENT_SUNSET}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local + # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC + # now = sunset + 1s -> 'before sunset' not true + now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"}, + ) + + # now = sunset - 1h-> 'before sunset' true + now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"}, + ) + + # now = local midnight -> 'before sunrise' true + now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-07-24T11:17:54.446913+00:00"}, + ) + + # now = local midnight - 1s -> 'before sunrise' not true + now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-07-23T11:22:18.467277+00:00"}, + ) + + +async def test_if_action_after_sunset_no_offset_kotzebue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise. + + Local timezone: Alaska time + Location: Kotzebue, which has a very skewed local timezone with sunrise + at 7 AM and sunset at 3AM during summer + After sunset is true from sunset until midnight, local time. + """ + await hass.config.async_set_time_zone("America/Anchorage") + hass.config.latitude = 66.5 + hass.config.longitude = 162.4 + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "after": SUN_EVENT_SUNSET}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local + # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC + # now = sunset -> 'after sunset' true + now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"}, + ) + + # now = sunset - 1s -> 'after sunset' not true + now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"}, + ) + + # now = local midnight -> 'after sunset' not true + now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-07-24T11:17:54.446913+00:00"}, + ) + + # now = local midnight - 1s -> 'after sunset' true + now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-07-23T11:22:18.467277+00:00"}, + ) diff --git a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr index 5ba65b2bd70..1fbd2c17a6c 100644 --- a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr +++ b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr @@ -21,12 +21,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Delay', 'platform': 'swiss_public_transport', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'delay', 'unique_id': 'Zürich Bern_delay', @@ -77,6 +81,7 @@ 'original_name': 'Departure', 'platform': 'swiss_public_transport', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'departure0', 'unique_id': 'Zürich Bern_departure', @@ -126,6 +131,7 @@ 'original_name': 'Departure +1', 'platform': 'swiss_public_transport', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'departure1', 'unique_id': 'Zürich Bern_departure1', @@ -175,6 +181,7 @@ 'original_name': 'Departure +2', 'platform': 'swiss_public_transport', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'departure2', 'unique_id': 'Zürich Bern_departure2', @@ -224,6 +231,7 @@ 'original_name': 'Line', 'platform': 'swiss_public_transport', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'line', 'unique_id': 'Zürich Bern_line', @@ -272,6 +280,7 @@ 'original_name': 'Platform', 'platform': 'swiss_public_transport', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'platform', 'unique_id': 'Zürich Bern_platform', @@ -320,6 +329,7 @@ 'original_name': 'Transfers', 'platform': 'swiss_public_transport', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'transfers', 'unique_id': 'Zürich Bern_transfers', @@ -362,6 +372,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -371,6 +384,7 @@ 'original_name': 'Trip duration', 'platform': 'swiss_public_transport', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'trip_duration', 'unique_id': 'Zürich Bern_duration', @@ -390,6 +404,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.003', + 'state': '0.00277777777777778', }) # --- diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py index 6e832728277..e677be44e3b 100644 --- a/tests/components/swiss_public_transport/test_sensor.py +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -8,7 +8,7 @@ from opendata_transport.exceptions import ( OpendataTransportError, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.swiss_public_transport.const import ( @@ -83,7 +83,10 @@ async def test_fetching_data( hass.states.get("sensor.zurich_bern_departure_2").state == "2024-01-06T17:05:00+00:00" ) - assert hass.states.get("sensor.zurich_bern_trip_duration").state == "0.003" + assert ( + round(float(hass.states.get("sensor.zurich_bern_trip_duration").state), 3) + == 0.003 + ) assert hass.states.get("sensor.zurich_bern_platform").state == "0" assert hass.states.get("sensor.zurich_bern_transfers").state == "0" assert hass.states.get("sensor.zurich_bern_delay").state == "0" @@ -139,7 +142,6 @@ async def test_fetching_data_setup_exception( """Test fetching data with setup exception.""" mock_opendata_client.async_get_data.side_effect = raise_error - await setup_integration(hass, swiss_public_transport_config_entry) assert swiss_public_transport_config_entry.state is state diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index cd80fab69bc..2c87b0e3a92 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest +from homeassistant.components import switch_as_x from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.lock import LockState from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler @@ -24,8 +25,9 @@ from homeassistant.const import ( EntityCategory, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component from . import PLATFORMS_TO_TEST @@ -39,6 +41,44 @@ EXPOSE_SETTINGS = { } +@pytest.fixture +def switch_entity_registry_entry( + entity_registry: er.EntityRegistry, +) -> er.RegistryEntry: + """Fixture to create a switch entity entry.""" + return entity_registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) + + +@pytest.fixture +def switch_as_x_config_entry( + hass: HomeAssistant, + switch_entity_registry_entry: er.RegistryEntry, + target_domain: str, + use_entity_registry_id: bool, +) -> MockConfigEntry: + """Fixture to create a switch_as_x config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_registry_entry.id + if use_entity_registry_id + else switch_entity_registry_entry.entity_id, + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_entry_unregistered_uuid( hass: HomeAssistant, target_domain: str @@ -67,6 +107,7 @@ async def test_config_entry_unregistered_uuid( assert len(hass.states.async_all()) == 0 +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.parametrize( ("target_domain", "state_on", "state_off"), [ @@ -81,33 +122,17 @@ async def test_config_entry_unregistered_uuid( async def test_entity_registry_events( hass: HomeAssistant, entity_registry: er.EntityRegistry, + switch_entity_registry_entry: er.RegistryEntry, + switch_as_x_config_entry: MockConfigEntry, target_domain: str, state_on: str, state_off: str, ) -> None: """Test entity registry events are tracked.""" - registry_entry = entity_registry.async_get_or_create( - "switch", "test", "unique", original_name="ABC" - ) - switch_entity_id = registry_entry.entity_id + switch_entity_id = switch_entity_registry_entry.entity_id hass.states.async_set(switch_entity_id, STATE_ON) - config_entry = MockConfigEntry( - data={}, - domain=DOMAIN, - options={ - CONF_ENTITY_ID: registry_entry.id, - CONF_INVERT: False, - CONF_TARGET_DOMAIN: target_domain, - }, - title="ABC", - version=SwitchAsXConfigFlowHandler.VERSION, - minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, - ) - - config_entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() assert hass.states.get(f"{target_domain}.abc").state == state_on @@ -199,16 +224,39 @@ async def test_device_registry_config_entry_1( device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id in device_entry.config_entries - # Remove the wrapped switch's config entry from the device - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=switch_config_entry.entry_id - ) - await hass.async_block_till_done() - await hass.async_block_till_done() + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) + + # Remove the wrapped switch's config entry from the device, this removes the + # wrapped switch + with patch( + "homeassistant.components.switch_as_x.async_unload_entry", + wraps=switch_as_x.async_unload_entry, + ) as mock_setup_entry: + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=switch_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + # Check that the switch_as_x config entry is removed from the device device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id not in device_entry.config_entries + # Check that the switch_as_x config entry is removed + assert ( + switch_as_x_config_entry.entry_id not in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == ["remove"] + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_device_registry_config_entry_2( @@ -258,13 +306,121 @@ async def test_device_registry_config_entry_2( device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id in device_entry.config_entries + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) + # Remove the wrapped switch from the device - entity_registry.async_update_entity(switch_entity_entry.entity_id, device_id=None) - await hass.async_block_till_done() + with patch( + "homeassistant.components.switch_as_x.async_unload_entry", + wraps=switch_as_x.async_unload_entry, + ) as mock_setup_entry: + entity_registry.async_update_entity( + switch_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + # Check that the switch_as_x config entry is removed from the device device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id not in device_entry.config_entries + # Check that the switch_as_x config entry is not removed + assert switch_as_x_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_device_registry_config_entry_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + target_domain: str, +) -> None: + """Test we add our config entry to the tracked switch's device.""" + switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_entry_2 = device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + switch_entity_entry = entity_registry.async_get_or_create( + "switch", + "test", + "unique", + config_entry=switch_config_entry, + device_id=device_entry.id, + original_name="ABC", + ) + + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + + switch_as_x_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get(f"{target_domain}.abc") + assert entity_entry.device_id == switch_entity_entry.device_id + + device_entry = device_registry.async_get(device_entry.id) + assert switch_as_x_config_entry.entry_id in device_entry.config_entries + device_entry_2 = device_registry.async_get(device_entry_2.id) + assert switch_as_x_config_entry.entry_id not in device_entry_2.config_entries + + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) + + # Move the wrapped switch to another device + with patch( + "homeassistant.components.switch_as_x.async_unload_entry", + wraps=switch_as_x.async_unload_entry, + ) as mock_setup_entry: + entity_registry.async_update_entity( + switch_entity_entry.entity_id, device_id=device_entry_2.id + ) + await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + + # Check that the switch_as_x config entry is moved to the other device + device_entry = device_registry.async_get(device_entry.id) + assert switch_as_x_config_entry.entry_id not in device_entry.config_entries + device_entry_2 = device_registry.async_get(device_entry_2.id) + assert switch_as_x_config_entry.entry_id in device_entry_2.config_entries + + # Check that the switch_as_x config entry is not removed + assert switch_as_x_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_entry_entity_id( diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 941d58c8e3a..5dca8167e05 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -47,6 +47,14 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: return entry +def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None): + """Patch async ble device from address to return a given value.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + WOHAND_SERVICE_INFO = BluetoothServiceInfoBleak( name="WoHand", manufacturer_data={89: b"\xfd`0U\x92W"}, @@ -555,3 +563,299 @@ CIRCULATOR_FAN_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + + +K20_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K20 Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x01\xf3\x8f'\x01\x11S\x00\x10d\x0f", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b".\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="K20 Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x01\xf3\x8f'\x01\x11S\x00\x10d\x0f", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b".\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K20 Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +K10_PRO_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K10 Pro Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeP\x8d\x8d\x02 d", + }, + 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="K10 Pro Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeP\x8d\x8d\x02 d", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"(\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K10 Pro Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +K10_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K10 Vacuum", + manufacturer_data={ + 2409: b"\xca8\x06\xa9_\xf1\x02 d", + }, + 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="K10 Vacuum", + manufacturer_data={ + 2409: b"\xca8\x06\xa9_\xf1\x02 d", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"}\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K10 Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +K10_POR_COMBO_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K10 Pro Combo Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x01\xf4\x1d\x0b\x01\x01\xb1\x03\x118\x01", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"3\x00\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="K10 Pro Combo Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x01\xf4\x1d\x0b\x01\x01\xb1\x03\x118\x01", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"3\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K10 Pro Combo Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +S10_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="S10 Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x00\x08|\n\x01\x11\x05\x00\x10M\x02", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"z\x00\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="S10 Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x00\x08|\n\x01\x11\x05\x00\x10M\x02", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"z\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "S10 Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +HUB3_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Hub3", + manufacturer_data={ + 2409: b"\xb0\xe9\xfen^)\x00\xffh&\xd6d\x83\x03\x994\x80", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00d\x00\x10\xb9@"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Hub3", + manufacturer_data={ + 2409: b"\xb0\xe9\xfen^)\x00\xffh&\xd6d\x83\x03\x994\x80", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00d\x00\x10\xb9@" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Hub3"), + time=0, + connectable=True, + tx_power=-127, +) + + +LOCK_LITE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Lock Lite", + manufacturer_data={2409: b"\xe9\xd5\x11\xb2kS\x17\x93\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="Lock Lite", + manufacturer_data={2409: b"\xe9\xd5\x11\xb2kS\x17\x93\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", "Lock Lite"), + time=0, + connectable=True, + tx_power=-127, +) + + +LOCK_ULTRA_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Lock Ultra", + manufacturer_data={2409: b"\xb0\xe9\xfe\xb6j=%\x8204\x00\x04"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x804\x00\x10\xa5\xb8"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Lock Ultra", + manufacturer_data={2409: b"\xb0\xe9\xfe\xb6j=%\x8204\x00\x04"}, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x804\x00\x10\xa5\xb8" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Lock Ultra"), + time=0, + connectable=True, + tx_power=-127, +) + + +AIR_PURIFIER_TBALE_PM25_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Air Purifier Table PM25", + manufacturer_data={ + 2409: b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"7\x00\x00\x95-\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="Air Purifier Table PM25", + manufacturer_data={ + 2409: b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"7\x00\x00\x95-\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier Table PM25"), + time=0, + connectable=True, + tx_power=-127, +) + + +AIR_PURIFIER_PM25_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Air Purifier PM25", + manufacturer_data={ + 2409: b'\xcc\x8d\xa2\xa7\x92>\t"\x80\x000\x00\x0f\x00\x00', + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"*\x00\x00\x15\x04\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="Air Purifier PM25", + manufacturer_data={ + 2409: b'\xcc\x8d\xa2\xa7\x92>\t"\x80\x000\x00\x0f\x00\x00', + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"*\x00\x00\x15\x04\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier PM25"), + time=0, + connectable=True, + tx_power=-127, +) + + +AIR_PURIFIER_VOC_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Air Purifier VOC", + manufacturer_data={ + 2409: b"\xcc\x8d\xa2\xa7\xe4\xa6\x0b\x83\x88d\x00\xea`\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"+\x00\x00\x15\x04\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="Air Purifier VOC", + manufacturer_data={ + 2409: b"\xcc\x8d\xa2\xa7\xe4\xa6\x0b\x83\x88d\x00\xea`\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"+\x00\x00\x15\x04\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier VOC"), + time=0, + connectable=True, + tx_power=-127, +) + + +AIR_PURIFIER_TABLE_VOC_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Air Purifier Table VOC", + manufacturer_data={ + 2409: b"\xcc\x8d\xa2\xa7\xc1\xae\x9b\x81\x8c\xb2\x00\x01\x94\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"8\x00\x00\x95-\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="Air Purifier Table VOC", + manufacturer_data={ + 2409: b"\xcc\x8d\xa2\xa7\xc1\xae\x9b\x81\x8c\xb2\x00\x01\x94\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"8\x00\x00\x95-\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier Table VOC"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index b52436f1932..9430a45d106 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -3,6 +3,10 @@ from collections.abc import Callable from unittest.mock import AsyncMock, patch +import pytest +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, @@ -23,6 +27,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER_TILT, ) from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from . import ( ROLLER_SHADE_SERVICE_INFO, @@ -490,3 +495,156 @@ async def test_roller_shade_controlling( state = hass.states.get(entity_id) assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ( + "sensor_type", + "service_info", + "class_name", + "service", + "service_data", + "mock_method", + ), + [ + ( + "curtain", + WOCURTAIN3_SERVICE_INFO, + "SwitchbotCurtain", + SERVICE_CLOSE_COVER, + {}, + "close", + ), + ( + "curtain", + WOCURTAIN3_SERVICE_INFO, + "SwitchbotCurtain", + SERVICE_OPEN_COVER, + {}, + "open", + ), + ( + "curtain", + WOCURTAIN3_SERVICE_INFO, + "SwitchbotCurtain", + SERVICE_STOP_COVER, + {}, + "stop", + ), + ( + "curtain", + WOCURTAIN3_SERVICE_INFO, + "SwitchbotCurtain", + SERVICE_SET_COVER_POSITION, + {ATTR_POSITION: 50}, + "set_position", + ), + ( + "roller_shade", + ROLLER_SHADE_SERVICE_INFO, + "SwitchbotRollerShade", + SERVICE_SET_COVER_POSITION, + {ATTR_POSITION: 50}, + "set_position", + ), + ( + "roller_shade", + ROLLER_SHADE_SERVICE_INFO, + "SwitchbotRollerShade", + SERVICE_OPEN_COVER, + {}, + "open", + ), + ( + "roller_shade", + ROLLER_SHADE_SERVICE_INFO, + "SwitchbotRollerShade", + SERVICE_CLOSE_COVER, + {}, + "close", + ), + ( + "roller_shade", + ROLLER_SHADE_SERVICE_INFO, + "SwitchbotRollerShade", + SERVICE_STOP_COVER, + {}, + "stop", + ), + ( + "blind_tilt", + WOBLINDTILT_SERVICE_INFO, + "SwitchbotBlindTilt", + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_TILT_POSITION: 50}, + "set_position", + ), + ( + "blind_tilt", + WOBLINDTILT_SERVICE_INFO, + "SwitchbotBlindTilt", + SERVICE_OPEN_COVER_TILT, + {}, + "open", + ), + ( + "blind_tilt", + WOBLINDTILT_SERVICE_INFO, + "SwitchbotBlindTilt", + SERVICE_CLOSE_COVER_TILT, + {}, + "close", + ), + ( + "blind_tilt", + WOBLINDTILT_SERVICE_INFO, + "SwitchbotBlindTilt", + SERVICE_STOP_COVER_TILT, + {}, + "stop", + ), + ], +) +async def test_exception_handling_cover_service( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + class_name: str, + service: str, + service_data: dict, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for cover service with exception.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + entity_id = "cover.test_name" + + with patch.multiple( + f"homeassistant.components.switchbot.cover.switchbot.{class_name}", + update=AsyncMock(return_value=None), + **{mock_method: AsyncMock(side_effect=exception)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + COVER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_diagnostics.py b/tests/components/switchbot/test_diagnostics.py index e5974459e09..7b7617498fd 100644 --- a/tests/components/switchbot/test_diagnostics.py +++ b/tests/components/switchbot/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.switchbot.const import ( diff --git a/tests/components/switchbot/test_fan.py b/tests/components/switchbot/test_fan.py index 815d3aceda3..bd0306a133c 100644 --- a/tests/components/switchbot/test_fan.py +++ b/tests/components/switchbot/test_fan.py @@ -4,7 +4,9 @@ from collections.abc import Callable from unittest.mock import AsyncMock, patch import pytest +from switchbot.devices.device import SwitchbotOperationError +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.fan import ( ATTR_OSCILLATING, ATTR_PERCENTAGE, @@ -16,8 +18,15 @@ from homeassistant.components.fan import ( ) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError -from . import CIRCULATOR_FAN_SERVICE_INFO +from . import ( + AIR_PURIFIER_PM25_SERVICE_INFO, + AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, + AIR_PURIFIER_TBALE_PM25_SERVICE_INFO, + AIR_PURIFIER_VOC_SERVICE_INFO, + CIRCULATOR_FAN_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -89,3 +98,132 @@ async def test_circulator_fan_controlling( ) mocked_instance.assert_awaited_once() + + +@pytest.mark.parametrize( + ("service_info", "sensor_type"), + [ + (AIR_PURIFIER_VOC_SERVICE_INFO, "air_purifier"), + (AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, "air_purifier_table"), + (AIR_PURIFIER_PM25_SERVICE_INFO, "air_purifier"), + (AIR_PURIFIER_TBALE_PM25_SERVICE_INFO, "air_purifier_table"), + ], +) +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: "sleep"}, + "set_preset_mode", + ), + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + ), + ], +) +async def test_air_purifier_controlling( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service_info: BluetoothServiceInfoBleak, + sensor_type: str, + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test controlling the air purifier with different services.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type) + 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.SwitchbotAirPurifier", + get_basic_info=mcoked_none_instance, + update=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() + + +@pytest.mark.parametrize( + ("service_info", "sensor_type"), + [ + (AIR_PURIFIER_VOC_SERVICE_INFO, "air_purifier"), + (AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, "air_purifier_table"), + (AIR_PURIFIER_PM25_SERVICE_INFO, "air_purifier"), + (AIR_PURIFIER_TBALE_PM25_SERVICE_INFO, "air_purifier_table"), + ], +) +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "sleep"}, "set_preset_mode"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_TURN_ON, {}, "turn_on"), + ], +) +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +async def test_exception_handling_air_purifier_service( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service_info: BluetoothServiceInfoBleak, + sensor_type: str, + service: str, + service_data: dict, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for air purifier service with exception.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type) + entry.add_to_hass(hass) + entity_id = "fan.test_name" + + mcoked_none_instance = AsyncMock(return_value=None) + with patch.multiple( + "homeassistant.components.switchbot.fan.switchbot.SwitchbotAirPurifier", + get_basic_info=mcoked_none_instance, + update=mcoked_none_instance, + **{mock_method: AsyncMock(side_effect=exception)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + FAN_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_humidifier.py b/tests/components/switchbot/test_humidifier.py index cb2882a7475..fa9efac0bfd 100644 --- a/tests/components/switchbot/test_humidifier.py +++ b/tests/components/switchbot/test_humidifier.py @@ -4,6 +4,7 @@ from collections.abc import Callable from unittest.mock import AsyncMock, patch import pytest +from switchbot.devices.device import SwitchbotOperationError from homeassistant.components.humidifier import ( ATTR_HUMIDITY, @@ -18,6 +19,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import HUMIDIFIER_SERVICE_INFO @@ -121,3 +123,53 @@ async def test_humidifier_services( } mock_instance = mock_map[mock_method] mock_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_TURN_ON, {}, "turn_on"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: 60}, "set_level"), + (SERVICE_SET_MODE, {ATTR_MODE: MODE_AUTO}, "async_set_auto"), + (SERVICE_SET_MODE, {ATTR_MODE: MODE_NORMAL}, "async_set_manual"), + ], +) +async def test_exception_handling_humidifier_service( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for humidifier service with exception.""" + 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" + + patch_target = f"homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.{mock_method}" + + with patch(patch_target, new=AsyncMock(side_effect=exception)): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_init.py b/tests/components/switchbot/test_init.py new file mode 100644 index 00000000000..8969557bc0f --- /dev/null +++ b/tests/components/switchbot/test_init.py @@ -0,0 +1,91 @@ +"""Test the switchbot init.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from . import ( + HUBMINI_MATTER_SERVICE_INFO, + LOCK_SERVICE_INFO, + patch_async_ble_device_from_address, +) + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + ValueError("wrong model"), + "Switchbot device initialization failed because of incorrect configuration parameters: wrong model", + ), + ], +) +async def test_exception_handling_for_device_initialization( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + exception: Exception, + error_message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exception handling for lock initialization.""" + inject_bluetooth_service_info(hass, LOCK_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="lock") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.lock.switchbot.SwitchbotLock.__init__", + side_effect=exception, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert error_message in caplog.text + + +async def test_setup_entry_without_ble_device( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup entry without ble device.""" + + entry = mock_entry_factory("hygrometer_co2") + entry.add_to_hass(hass) + + with patch_async_ble_device_from_address(None): + await hass.config_entries.async_setup(entry.entry_id) + + assert ( + "Could not find Switchbot hygrometer_co2 with address aa:bb:cc:dd:ee:ff" + in caplog.text + ) + + +async def test_coordinator_wait_ready_timeout( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the coordinator async_wait_ready timeout by calling it directly.""" + + inject_bluetooth_service_info(hass, HUBMINI_MATTER_SERVICE_INFO) + + entry = mock_entry_factory("hubmini_matter") + entry.add_to_hass(hass) + + timeout_mock = AsyncMock() + timeout_mock.__aenter__.side_effect = TimeoutError + timeout_mock.__aexit__.return_value = None + + with patch( + "homeassistant.components.switchbot.coordinator.asyncio.timeout", + return_value=timeout_mock, + ): + await hass.config_entries.async_setup(entry.entry_id) + + assert "aa:bb:cc:dd:ee:ff is not advertising state" in caplog.text diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py index ef46017e9ae..957d56411da 100644 --- a/tests/components/switchbot/test_light.py +++ b/tests/components/switchbot/test_light.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch import pytest from switchbot import ColorMode as switchbotColorMode +from switchbot.devices.device import SwitchbotOperationError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -17,6 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import WOSTRIP_SERVICE_INFO @@ -93,30 +95,14 @@ async def test_light_strip_services( 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)), + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", + color_modes=color_modes, + color_mode=color_mode, + update=AsyncMock(return_value=None), + **{mock_method: mocked_instance}, ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -128,12 +114,90 @@ async def test_light_strip_services( 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) + mocked_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ("service", "service_data", "mock_method", "color_modes", "color_mode"), + [ + ( + SERVICE_TURN_ON, + {}, + "turn_on", + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128}, + "set_brightness", + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_RGB_COLOR: (255, 0, 0)}, + "set_rgb", + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_COLOR_TEMP_KELVIN: 4000}, + "set_color_temp", + {switchbotColorMode.COLOR_TEMP}, + switchbotColorMode.COLOR_TEMP, + ), + ], +) +async def test_exception_handling_light_service( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + color_modes: set | None, + color_mode: switchbotColorMode | None, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for light service with exception.""" + 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.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", + color_modes=color_modes, + color_mode=color_mode, + update=AsyncMock(return_value=None), + **{mock_method: AsyncMock(side_effect=exception)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_lock.py b/tests/components/switchbot/test_lock.py index b7153a041d0..38b8d24523b 100644 --- a/tests/components/switchbot/test_lock.py +++ b/tests/components/switchbot/test_lock.py @@ -4,6 +4,7 @@ from collections.abc import Callable from unittest.mock import AsyncMock, MagicMock, patch import pytest +from switchbot.devices.device import SwitchbotOperationError from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN @@ -14,8 +15,14 @@ from homeassistant.const import ( SERVICE_UNLOCK, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError -from . import LOCK_SERVICE_INFO, WOLOCKPRO_SERVICE_INFO +from . import ( + LOCK_LITE_SERVICE_INFO, + LOCK_SERVICE_INFO, + LOCK_ULTRA_SERVICE_INFO, + WOLOCKPRO_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -23,7 +30,12 @@ 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)], + [ + ("lock_pro", WOLOCKPRO_SERVICE_INFO), + ("lock", LOCK_SERVICE_INFO), + ("lock_lite", LOCK_LITE_SERVICE_INFO), + ("lock_ultra", LOCK_ULTRA_SERVICE_INFO), + ], ) @pytest.mark.parametrize( ("service", "mock_method"), @@ -42,10 +54,13 @@ async def test_lock_services( entry = mock_entry_encrypted_factory(sensor_type=sensor_type) entry.add_to_hass(hass) + mocked_instance = AsyncMock(return_value=True) - with patch( - f"homeassistant.components.switchbot.lock.switchbot.SwitchbotLock.{mock_method}", - ) as mocked_instance: + with patch.multiple( + "homeassistant.components.switchbot.lock.switchbot.SwitchbotLock", + update=AsyncMock(return_value=None), + **{mock_method: mocked_instance}, + ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -63,7 +78,12 @@ async def test_lock_services( @pytest.mark.parametrize( ("sensor_type", "service_info"), - [("lock_pro", WOLOCKPRO_SERVICE_INFO), ("lock", LOCK_SERVICE_INFO)], + [ + ("lock_pro", WOLOCKPRO_SERVICE_INFO), + ("lock", LOCK_SERVICE_INFO), + ("lock_lite", LOCK_LITE_SERVICE_INFO), + ("lock_ultra", LOCK_ULTRA_SERVICE_INFO), + ], ) @pytest.mark.parametrize( ("service", "mock_method"), @@ -82,12 +102,12 @@ async def test_lock_services_with_night_latch_enabled( 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), + update=AsyncMock(return_value=None), **{mock_method: mocked_instance}, ): assert await hass.config_entries.async_setup(entry.entry_id) @@ -103,3 +123,53 @@ async def test_lock_services_with_night_latch_enabled( ) mocked_instance.assert_awaited_once() + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_LOCK, "lock"), + (SERVICE_OPEN, "unlock"), + (SERVICE_UNLOCK, "unlock_without_unlatch"), + ], +) +async def test_exception_handling_lock_service( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for lock service with exception.""" + inject_bluetooth_service_info(hass, LOCK_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="lock") + entry.add_to_hass(hass) + entity_id = "lock.test_name" + + with patch.multiple( + "homeassistant.components.switchbot.lock.switchbot.SwitchbotLock", + is_night_latch_enabled=MagicMock(return_value=True), + update=AsyncMock(return_value=None), + **{mock_method: AsyncMock(side_effect=exception)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LOCK_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 8b1e6c83f21..a04bff75c2d 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.setup import async_setup_component from . import ( CIRCULATOR_FAN_SERVICE_INFO, + HUB3_SERVICE_INFO, HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, REMOTE_SERVICE_INFO, @@ -385,3 +386,63 @@ async def test_fan_sensors(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_hub3_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for Hub3.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, HUB3_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hub3", + }, + 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")) == 5 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "25.3" + 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 == "52" + 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" + + light_level_sensor = hass.states.get("sensor.test_name_light_level") + light_level_sensor_attrs = light_level_sensor.attributes + assert light_level_sensor.state == "3" + assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Light level" + assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "Level" + assert light_level_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + illuminance_sensor = hass.states.get("sensor.test_name_illuminance") + illuminance_sensor_attrs = illuminance_sensor.attributes + assert illuminance_sensor.state == "90" + assert illuminance_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Illuminance" + assert illuminance_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "lx" + assert illuminance_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + 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 index 2d572fd9996..be28b2a02a8 100644 --- a/tests/components/switchbot/test_switch.py +++ b/tests/components/switchbot/test_switch.py @@ -1,10 +1,20 @@ """Test the switchbot switches.""" from collections.abc import Callable -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from homeassistant.components.switch import STATE_ON +import pytest +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from . import WOHAND_SERVICE_INFO @@ -45,3 +55,51 @@ async def test_switchbot_switch_with_restore_state( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes["last_run_success"] is True + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_TURN_ON, "turn_on"), + (SERVICE_TURN_OFF, "turn_off"), + ], +) +async def test_exception_handling_switch( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for switch service with exception.""" + inject_bluetooth_service_info(hass, WOHAND_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="bot") + entry.add_to_hass(hass) + entity_id = "switch.test_name" + + patch_target = ( + f"homeassistant.components.switchbot.switch.switchbot.Switchbot.{mock_method}" + ) + + with patch(patch_target, new=AsyncMock(side_effect=exception)): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_vacuum.py b/tests/components/switchbot/test_vacuum.py new file mode 100644 index 00000000000..7822bda15db --- /dev/null +++ b/tests/components/switchbot/test_vacuum.py @@ -0,0 +1,77 @@ +"""Tests for switchbot vacuum.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + SERVICE_START, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import ( + K10_POR_COMBO_VACUUM_SERVICE_INFO, + K10_PRO_VACUUM_SERVICE_INFO, + K10_VACUUM_SERVICE_INFO, + K20_VACUUM_SERVICE_INFO, + S10_VACUUM_SERVICE_INFO, +) + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("k20_vacuum", K20_VACUUM_SERVICE_INFO), + ("s10_vacuum", S10_VACUUM_SERVICE_INFO), + ("k10_pro_combo_vacumm", K10_POR_COMBO_VACUUM_SERVICE_INFO), + ("k10_vacuum", K10_VACUUM_SERVICE_INFO), + ("k10_pro_vacuum", K10_PRO_VACUUM_SERVICE_INFO), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [(SERVICE_START, "clean_up"), (SERVICE_RETURN_TO_BASE, "return_to_dock")], +) +async def test_vacuum_controlling( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service: str, + mock_method: str, + service_info: BluetoothServiceInfoBleak, +) -> None: + """Test switchbot vacuum controlling.""" + + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_factory(sensor_type) + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.vacuum.switchbot.SwitchbotVacuum", + update=MagicMock(return_value=None), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "vacuum.test_name" + + await hass.services.async_call( + VACUUM_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index 2446add959b..83d4fa6b5a3 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'switchbot_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'meter-id-1_battery', @@ -81,6 +82,7 @@ 'original_name': 'Humidity', 'platform': 'switchbot_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'meter-id-1_humidity', @@ -127,12 +129,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'switchbot_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'meter-id-1_temperature', @@ -185,6 +191,7 @@ 'original_name': 'Battery', 'platform': 'switchbot_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'meter-id-1_battery', @@ -237,6 +244,7 @@ 'original_name': 'Humidity', 'platform': 'switchbot_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'meter-id-1_humidity', @@ -283,12 +291,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'switchbot_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'meter-id-1_temperature', diff --git a/tests/components/switchbot_cloud/test_config_flow.py b/tests/components/switchbot_cloud/test_config_flow.py index 1d49b503ef2..5eef1805a5a 100644 --- a/tests/components/switchbot_cloud/test_config_flow.py +++ b/tests/components/switchbot_cloud/test_config_flow.py @@ -6,8 +6,8 @@ import pytest from homeassistant import config_entries from homeassistant.components.switchbot_cloud.config_flow import ( - CannotConnect, - InvalidAuth, + SwitchBotAuthenticationError, + SwitchBotConnectionError, ) from homeassistant.components.switchbot_cloud.const import DOMAIN, ENTRY_TITLE from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN @@ -57,8 +57,8 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @pytest.mark.parametrize( ("error", "message"), [ - (InvalidAuth, "invalid_auth"), - (CannotConnect, "cannot_connect"), + (SwitchBotAuthenticationError, "invalid_auth"), + (SwitchBotConnectionError, "cannot_connect"), (Exception, "unknown"), ], ) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index b2d1cff6679..b55106e90d9 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -3,15 +3,24 @@ from unittest.mock import patch import pytest -from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState, Remote +from switchbot_api import ( + Device, + PowerState, + Remote, + SwitchBotAuthenticationError, + SwitchBotConnectionError, +) from homeassistant.components.switchbot_cloud import SwitchBotAPI from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant +from homeassistant.core_config import async_process_ha_core_config from . import configure_integration +from tests.typing import ClientSessionGenerator + @pytest.fixture def mock_list_devices(): @@ -27,10 +36,43 @@ def mock_get_status(): yield mock_get_status +@pytest.fixture +def mock_get_webook_configuration(): + """Mock get_status.""" + with patch.object( + SwitchBotAPI, "get_webook_configuration" + ) as mock_get_webook_configuration: + yield mock_get_webook_configuration + + +@pytest.fixture +def mock_delete_webhook(): + """Mock get_status.""" + with patch.object(SwitchBotAPI, "delete_webhook") as mock_delete_webhook: + yield mock_delete_webhook + + +@pytest.fixture +def mock_setup_webhook(): + """Mock get_status.""" + with patch.object(SwitchBotAPI, "setup_webhook") as mock_setup_webhook: + yield mock_setup_webhook + + async def test_setup_entry_success( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_get_webook_configuration, + mock_delete_webhook, + mock_setup_webhook, ) -> None: """Test successful setup of entry.""" + await async_process_ha_core_config( + hass, + {"external_url": "https://example.com"}, + ) + mock_get_webook_configuration.return_value = {"urls": ["https://example.com"]} mock_list_devices.return_value = [ Remote( version="V1.0", @@ -67,8 +109,15 @@ async def test_setup_entry_success( deviceType="Hub 2", hubDeviceId="test-hub-id", ), + Device( + deviceId="vacuum-1", + deviceName="vacuum-name-1", + deviceType="K10+", + hubDeviceId=None, + ), ] mock_get_status.return_value = {"power": PowerState.ON.value} + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED @@ -76,13 +125,16 @@ async def test_setup_entry_success( await hass.async_block_till_done() mock_list_devices.assert_called_once() mock_get_status.assert_called() + mock_get_webook_configuration.assert_called_once() + mock_delete_webhook.assert_called_once() + mock_setup_webhook.assert_called_once() @pytest.mark.parametrize( ("error", "state"), [ - (InvalidAuth, ConfigEntryState.SETUP_ERROR), - (CannotConnect, ConfigEntryState.SETUP_RETRY), + (SwitchBotAuthenticationError, ConfigEntryState.SETUP_ERROR), + (SwitchBotConnectionError, ConfigEntryState.SETUP_RETRY), ], ) async def test_setup_entry_fails_when_listing_devices( @@ -116,7 +168,7 @@ async def test_setup_entry_fails_when_refreshing( hubDeviceId="test-hub-id", ) ] - mock_get_status.side_effect = CannotConnect + mock_get_status.side_effect = SwitchBotConnectionError entry = await configure_integration(hass) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -124,3 +176,52 @@ async def test_setup_entry_fails_when_refreshing( await hass.async_block_till_done() mock_list_devices.assert_called_once() mock_get_status.assert_called() + + +async def test_posting_to_webhook( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_get_webook_configuration, + mock_delete_webhook, + mock_setup_webhook, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test handler webhook call.""" + await async_process_ha_core_config( + hass, + {"external_url": "https://example.com"}, + ) + mock_get_webook_configuration.return_value = {"urls": ["https://example.com"]} + mock_list_devices.return_value = [ + Device( + deviceId="vacuum-1", + deviceName="vacuum-name-1", + deviceType="K10+", + hubDeviceId=None, + ), + ] + mock_get_status.return_value = {"power": PowerState.ON.value} + mock_delete_webhook.return_value = {} + mock_setup_webhook.return_value = {} + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + webhook_id = entry.data[CONF_WEBHOOK_ID] + client = await hass_client_no_auth() + # fire webhook + await client.post( + f"/api/webhook/{webhook_id}", + json={ + "eventType": "changeReport", + "eventVersion": "1", + "context": {"deviceType": "...", "deviceMac": "vacuum-1"}, + }, + ) + + await hass.async_block_till_done() + + mock_setup_webhook.assert_called_once() diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 1008dd72b47..0927e3cf1ea 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch from switchbot_api import Device -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switchbot_cloud.const import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/syncthru/snapshots/test_binary_sensor.ambr b/tests/components/syncthru/snapshots/test_binary_sensor.ambr index 4f8809fd984..41be0698ad9 100644 --- a/tests/components/syncthru/snapshots/test_binary_sensor.ambr +++ b/tests/components/syncthru/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Connectivity', 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '08HRB8GJ3F019DD_online', @@ -75,6 +76,7 @@ 'original_name': 'Problem', 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '08HRB8GJ3F019DD_problem', diff --git a/tests/components/syncthru/snapshots/test_sensor.ambr b/tests/components/syncthru/snapshots/test_sensor.ambr index b7edc046879..5d86fc41cc0 100644 --- a/tests/components/syncthru/snapshots/test_sensor.ambr +++ b/tests/components/syncthru/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '08HRB8GJ3F019DD_main', @@ -76,6 +77,7 @@ 'original_name': 'Active alerts', 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_alerts', 'unique_id': '08HRB8GJ3F019DD_active_alerts', @@ -125,6 +127,7 @@ 'original_name': 'Black toner level', 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'toner_black', 'unique_id': '08HRB8GJ3F019DD_toner_black', @@ -178,6 +181,7 @@ 'original_name': 'Cyan toner level', 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'toner_cyan', 'unique_id': '08HRB8GJ3F019DD_toner_cyan', @@ -231,6 +235,7 @@ 'original_name': 'Input tray 1', 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tray', 'unique_id': '08HRB8GJ3F019DD_tray_tray_1', @@ -287,6 +292,7 @@ 'original_name': 'Magenta toner level', 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'toner_magenta', 'unique_id': '08HRB8GJ3F019DD_toner_magenta', @@ -340,6 +346,7 @@ 'original_name': 'Output tray 1', 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'output_tray', 'unique_id': '08HRB8GJ3F019DD_output_tray_1', @@ -391,6 +398,7 @@ 'original_name': 'Yellow toner level', 'platform': 'syncthru', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'toner_yellow', 'unique_id': '08HRB8GJ3F019DD_toner_yellow', diff --git a/tests/components/syncthru/test_binary_sensor.py b/tests/components/syncthru/test_binary_sensor.py index ae5f0b6a90c..7067f553807 100644 --- a/tests/components/syncthru/test_binary_sensor.py +++ b/tests/components/syncthru/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/syncthru/test_diagnostics.py b/tests/components/syncthru/test_diagnostics.py index f5988936328..3ff4bc8cc08 100644 --- a/tests/components/syncthru/test_diagnostics.py +++ b/tests/components/syncthru/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/syncthru/test_sensor.py b/tests/components/syncthru/test_sensor.py index 600e2962730..78641739c8f 100644 --- a/tests/components/syncthru/test_sensor.py +++ b/tests/components/syncthru/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index db0062b45bf..0a887bbcae3 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -34,7 +34,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -from homeassistant.util.aiohttp import MockStreamReader +from homeassistant.util.aiohttp import MockStreamReader, MockStreamReaderChunked from .common import mock_dsm_information from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME @@ -45,14 +45,6 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_2025-01-09_20.14_35457323" -class MockStreamReaderChunked(MockStreamReader): - """Mock a stream reader with simulated chunked data.""" - - async def readchunk(self) -> tuple[bytes, bool]: - """Read bytes.""" - return (self._content.read(), False) - - async def _mock_download_file(path: str, filename: str) -> MockStreamReader: if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader( @@ -350,14 +342,16 @@ async def test_agents_list_backups( } }, "backup_id": "abcd12ef", - "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, + "date": "2025-01-09T20:14:35.457323+01:00", "extra_metadata": {"instance_id": ANY, "with_automatic_settings": True}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", "name": "Automatic backup 2025.2.0.dev0", - "failed_agent_ids": [], "with_automatic_settings": None, } ] @@ -421,14 +415,16 @@ async def test_agents_list_backups_disabled_filestation( } }, "backup_id": "abcd12ef", - "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, + "date": "2025-01-09T20:14:35.457323+01:00", "extra_metadata": {"instance_id": ANY, "with_automatic_settings": True}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", "name": "Automatic backup 2025.2.0.dev0", - "failed_agent_ids": [], "with_automatic_settings": None, }, ), diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 932cf057d3d..f2aa6df802e 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -12,7 +12,7 @@ from synology_dsm.exceptions import ( SynologyDSMLoginInvalidException, SynologyDSMRequestException, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index dd454f92137..d66688575bc 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -61,6 +61,11 @@ def dsm_with_photos() -> MagicMock: SynoPhotosItem(10, "", "filename.jpg", 12345, "10_1298753", "sm", True, ""), ] ) + dsm.photos.get_items_from_shared_space = AsyncMock( + return_value=[ + SynoPhotosItem(10, "", "filename.jpg", 12345, "10_1298753", "sm", True, ""), + ] + ) dsm.photos.get_item_thumbnail_url = AsyncMock( return_value="http://my.thumbnail.url" ) @@ -257,13 +262,16 @@ async def test_browse_media_get_albums( result = await source.async_browse_media(item) assert result - assert len(result.children) == 2 + assert len(result.children) == 3 assert isinstance(result.children[0], BrowseMedia) assert result.children[0].identifier == "mocked_syno_dsm_entry/0" assert result.children[0].title == "All images" assert isinstance(result.children[1], BrowseMedia) - assert result.children[1].identifier == "mocked_syno_dsm_entry/1_" - assert result.children[1].title == "Album 1" + assert result.children[1].identifier == "mocked_syno_dsm_entry/shared" + assert result.children[1].title == "Shared space" + assert isinstance(result.children[2], BrowseMedia) + assert result.children[2].identifier == "mocked_syno_dsm_entry/1_" + assert result.children[2].title == "Album 1" @pytest.mark.usefixtures("setup_media_source") @@ -315,6 +323,17 @@ async def test_browse_media_get_items_error( assert result.identifier is None assert len(result.children) == 0 + # exception in get_items_from_shared_space() + dsm_with_photos.photos.get_items_from_shared_space = AsyncMock( + side_effect=SynologyDSMException("", None) + ) + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/shared", None) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + @pytest.mark.usefixtures("setup_media_source") async def test_browse_media_get_items_thumbnail_error( @@ -411,6 +430,22 @@ async def test_browse_media_get_items( assert not item.can_expand assert item.thumbnail == "http://my.thumbnail.url" + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/shared", None) + result = await source.async_browse_media(item) + assert result + assert len(result.children) == 1 + item = result.children[0] + assert ( + item.identifier + == "mocked_syno_dsm_entry/shared_/10_1298753/filename.jpg_shared" + ) + assert item.title == "filename.jpg" + assert item.media_class == MediaClass.IMAGE + assert item.media_content_type == "image/jpeg" + assert item.can_play + assert not item.can_expand + assert item.thumbnail == "http://my.thumbnail.url" + @pytest.mark.usefixtures("setup_media_source") async def test_media_view( diff --git a/tests/components/systemmonitor/test_diagnostics.py b/tests/components/systemmonitor/test_diagnostics.py index 26e421e6574..f9bde984399 100644 --- a/tests/components/systemmonitor/test_diagnostics.py +++ b/tests/components/systemmonitor/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/tado/test_diagnostics.py b/tests/components/tado/test_diagnostics.py index 3a4f04b0a4c..36d136d5d77 100644 --- a/tests/components/tado/test_diagnostics.py +++ b/tests/components/tado/test_diagnostics.py @@ -1,6 +1,6 @@ """Test the Tado component diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.tado.const import DOMAIN diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index ac862e59f2d..25b1e116c04 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -5,7 +5,7 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.tag import DOMAIN, _create_entry, async_scan_tag diff --git a/tests/components/tailscale/test_diagnostics.py b/tests/components/tailscale/test_diagnostics.py index 26ba611438c..7dcf94f8ce8 100644 --- a/tests/components/tailscale/test_diagnostics.py +++ b/tests/components/tailscale/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Tailscale integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index d04f2e726b5..5d166018160 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -41,6 +41,7 @@ 'original_name': 'Operational problem', 'platform': 'tailwind', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operational_problem', 'unique_id': '_3c_e9_e_6d_21_84_-door1-locked_out', @@ -122,6 +123,7 @@ 'original_name': 'Operational problem', 'platform': 'tailwind', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operational_problem', 'unique_id': '_3c_e9_e_6d_21_84_-door2-locked_out', diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr index 7d3d10aa609..0e4bb4e4e41 100644 --- a/tests/components/tailwind/snapshots/test_button.ambr +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -41,6 +41,7 @@ 'original_name': 'Identify', 'platform': 'tailwind', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '_3c_e9_e_6d_21_84_-identify', diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index 1a26a6c98a7..a1a98b028e3 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -42,6 +42,7 @@ 'original_name': None, 'platform': 'tailwind', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '_3c_e9_e_6d_21_84_-door1', @@ -124,6 +125,7 @@ 'original_name': None, 'platform': 'tailwind', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '_3c_e9_e_6d_21_84_-door2', diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr index 7b906ef1976..ffa2c5df7fd 100644 --- a/tests/components/tailwind/snapshots/test_number.ambr +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -50,6 +50,7 @@ 'original_name': 'Status LED brightness', 'platform': 'tailwind', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brightness', 'unique_id': '_3c_e9_e_6d_21_84_-brightness', diff --git a/tests/components/tankerkoenig/test_binary_sensor.py b/tests/components/tankerkoenig/test_binary_sensor.py index c103f2d26ff..880eb0e2f8c 100644 --- a/tests/components/tankerkoenig/test_binary_sensor.py +++ b/tests/components/tankerkoenig/test_binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant diff --git a/tests/components/tankerkoenig/test_diagnostics.py b/tests/components/tankerkoenig/test_diagnostics.py index e7b479a0c32..6e1c81fa2c4 100644 --- a/tests/components/tankerkoenig/test_diagnostics.py +++ b/tests/components/tankerkoenig/test_diagnostics.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/tankerkoenig/test_sensor.py b/tests/components/tankerkoenig/test_sensor.py index 788c1de7021..27c2324662c 100644 --- a/tests/components/tankerkoenig/test_sensor.py +++ b/tests/components/tankerkoenig/test_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tankerkoenig import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/tasmota/snapshots/test_sensor.ambr b/tests/components/tasmota/snapshots/test_sensor.ambr index 8a5a78cd366..00b09239b26 100644 --- a/tests/components/tasmota/snapshots/test_sensor.ambr +++ b/tests/components/tasmota/snapshots/test_sensor.ambr @@ -39,12 +39,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHT11 Temperature', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_DHT11_Temperature', @@ -125,6 +129,7 @@ 'original_name': 'TX23 Speed Act', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_TX23_Speed_Act', @@ -172,6 +177,7 @@ 'original_name': 'TX23 Dir Card', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_TX23_Dir_Card', @@ -272,12 +278,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ENERGY TotalTariff 0', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ENERGY_TotalTariff_0', @@ -420,12 +430,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ENERGY TotalTariff 1', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ENERGY_TotalTariff_1', @@ -472,12 +486,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ENERGY ExportTariff 0', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ENERGY_ExportTariff_0', @@ -524,12 +542,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ENERGY ExportTariff 1', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ENERGY_ExportTariff_1', @@ -608,12 +630,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DS18B20 Temperature', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_DS18B20_Temperature', @@ -661,6 +687,7 @@ 'original_name': 'DS18B20 Id', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_DS18B20_Id', @@ -765,12 +792,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ENERGY Total', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ENERGY_Total', @@ -849,12 +880,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ENERGY Total 0', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ENERGY_Total_0', @@ -901,12 +936,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ENERGY Total 1', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ENERGY_Total_1', @@ -1017,12 +1056,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ENERGY Total Phase1', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ENERGY_Total_Phase1', @@ -1069,12 +1112,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ENERGY Total Phase2', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ENERGY_Total_Phase2', @@ -1185,12 +1232,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ANALOG Temperature1', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ANALOG_Temperature1', @@ -1269,12 +1320,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ANALOG Temperature2', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ANALOG_Temperature2', @@ -1327,6 +1382,7 @@ 'original_name': 'ANALOG Illuminance3', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ANALOG_Illuminance3', @@ -1437,12 +1493,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ANALOG CTEnergy1 Energy', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ANALOG_CTEnergy1_Energy', @@ -1585,12 +1645,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ANALOG CTEnergy1 Power', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ANALOG_CTEnergy1_Power', @@ -1637,12 +1701,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ANALOG CTEnergy1 Voltage', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ANALOG_CTEnergy1_Voltage', @@ -1689,12 +1757,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ANALOG CTEnergy1 Current', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_ANALOG_CTEnergy1_Current', @@ -1774,6 +1846,7 @@ 'original_name': 'SENSOR1 Unknown', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_SENSOR1_Unknown', @@ -1903,6 +1976,7 @@ 'original_name': 'SENSOR2 Unknown', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_SENSOR2_Unknown', @@ -1953,6 +2027,7 @@ 'original_name': 'SENSOR3 Unknown', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_SENSOR3_Unknown', @@ -2003,6 +2078,7 @@ 'original_name': 'SENSOR4 Unknown', 'platform': 'tasmota', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00000049A3BC_sensor_sensor_SENSOR4_Unknown', diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 78235f7ebf5..098cdbbf8d1 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -13,7 +13,7 @@ from hatasmota.utils import ( get_topic_tele_will, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.tasmota.const import DEFAULT_PREFIX diff --git a/tests/components/technove/snapshots/test_binary_sensor.ambr b/tests/components/technove/snapshots/test_binary_sensor.ambr index 5d9bcd2175a..7ab19670da4 100644 --- a/tests/components/technove/snapshots/test_binary_sensor.ambr +++ b/tests/components/technove/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery protected', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_battery_protected', 'unique_id': 'AA:AA:AA:AA:AA:BB_is_battery_protected', @@ -74,6 +75,7 @@ 'original_name': 'Conflict with power sharing mode', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'conflict_in_sharing_config', 'unique_id': 'AA:AA:AA:AA:AA:BB_conflict_in_sharing_config', @@ -121,6 +123,7 @@ 'original_name': 'Power sharing mode', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'in_sharing_mode', 'unique_id': 'AA:AA:AA:AA:AA:BB_in_sharing_mode', @@ -168,6 +171,7 @@ 'original_name': 'Static IP', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_static_ip', 'unique_id': 'AA:AA:AA:AA:AA:BB_is_static_ip', @@ -215,6 +219,7 @@ 'original_name': 'Update', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:AA:AA:AA:AA:BB_update_available', diff --git a/tests/components/technove/snapshots/test_number.ambr b/tests/components/technove/snapshots/test_number.ambr index eea4b0cb64c..1be2d26ad44 100644 --- a/tests/components/technove/snapshots/test_number.ambr +++ b/tests/components/technove/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Maximum current', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_current', 'unique_id': 'AA:AA:AA:AA:AA:BB_max_current', diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr index aaec5667e55..801cc9fd38e 100644 --- a/tests/components/technove/snapshots/test_sensor.ambr +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:AA:AA:AA:AA:BB_current', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Input voltage', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_in', 'unique_id': 'AA:AA:AA:AA:AA:BB_voltage_in', @@ -127,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Last session energy usage', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_session', 'unique_id': 'AA:AA:AA:AA:AA:BB_energy_session', @@ -179,12 +191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Max station current', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_station_current', 'unique_id': 'AA:AA:AA:AA:AA:BB_max_station_current', @@ -231,12 +247,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Output voltage', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_out', 'unique_id': 'AA:AA:AA:AA:AA:BB_voltage_out', @@ -289,6 +309,7 @@ 'original_name': 'Signal strength', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'AA:AA:AA:AA:AA:BB_rssi', @@ -347,6 +368,7 @@ 'original_name': 'Status', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', 'unique_id': 'AA:AA:AA:AA:AA:BB_status', @@ -398,12 +420,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy usage', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_total', 'unique_id': 'AA:AA:AA:AA:AA:BB_energy_total', @@ -454,6 +480,7 @@ 'original_name': 'Wi-Fi network name', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ssid', 'unique_id': 'AA:AA:AA:AA:AA:BB_ssid', diff --git a/tests/components/technove/snapshots/test_switch.ambr b/tests/components/technove/snapshots/test_switch.ambr index a5f8411747b..f8e86db58b5 100644 --- a/tests/components/technove/snapshots/test_switch.ambr +++ b/tests/components/technove/snapshots/test_switch.ambr @@ -24,9 +24,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto charge', + 'original_name': 'Auto-charge', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_charge', 'unique_id': 'AA:AA:AA:AA:AA:BB_auto_charge', @@ -36,7 +37,7 @@ # name: test_switches[switch.technove_station_auto_charge-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'TechnoVE Station Auto charge', + 'friendly_name': 'TechnoVE Station Auto-charge', }), 'context': , 'entity_id': 'switch.technove_station_auto_charge', @@ -71,9 +72,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Enabled', + 'original_name': 'Charging enabled', 'platform': 'technove', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'session_active', 'unique_id': 'AA:AA:AA:AA:AA:BB_session_active', @@ -83,7 +85,7 @@ # name: test_switches[switch.technove_station_charging_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'TechnoVE Station Charging Enabled', + 'friendly_name': 'TechnoVE Station Charging enabled', }), 'context': , 'entity_id': 'switch.technove_station_charging_enabled', diff --git a/tests/components/technove/test_binary_sensor.py b/tests/components/technove/test_binary_sensor.py index 93d4805cecb..cbc34534480 100644 --- a/tests/components/technove/test_binary_sensor.py +++ b/tests/components/technove/test_binary_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from technove import TechnoVEError from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform diff --git a/tests/components/technove/test_sensor.py b/tests/components/technove/test_sensor.py index 9cf80a659eb..48c59c80197 100644 --- a/tests/components/technove/test_sensor.py +++ b/tests/components/technove/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from technove import Station, Status, TechnoVEError from homeassistant.components.technove.const import DOMAIN diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr index c2210a7ca5d..05d0e34037e 100644 --- a/tests/components/tedee/snapshots/test_binary_sensor.ambr +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charging', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-charging', @@ -75,6 +76,7 @@ 'original_name': 'Lock uncalibrated', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uncalibrated', 'unique_id': '12345-uncalibrated', @@ -123,6 +125,7 @@ 'original_name': 'Pullspring enabled', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pullspring_enabled', 'unique_id': '12345-pullspring_enabled', @@ -170,6 +173,7 @@ 'original_name': 'Semi locked', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'semi_locked', 'unique_id': '12345-semi_locked', @@ -217,6 +221,7 @@ 'original_name': 'Charging', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '98765-charging', @@ -265,6 +270,7 @@ 'original_name': 'Lock uncalibrated', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uncalibrated', 'unique_id': '98765-uncalibrated', @@ -313,6 +319,7 @@ 'original_name': 'Pullspring enabled', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pullspring_enabled', 'unique_id': '98765-pullspring_enabled', @@ -360,6 +367,7 @@ 'original_name': 'Semi locked', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'semi_locked', 'unique_id': '98765-semi_locked', diff --git a/tests/components/tedee/snapshots/test_diagnostics.ambr b/tests/components/tedee/snapshots/test_diagnostics.ambr index 401c519c215..046a8fd210a 100644 --- a/tests/components/tedee/snapshots/test_diagnostics.ambr +++ b/tests/components/tedee/snapshots/test_diagnostics.ambr @@ -6,6 +6,7 @@ 'duration_pullspring': 2, 'is_charging': False, 'is_connected': True, + 'is_enabled_auto_pullspring': False, 'is_enabled_pullspring': 1, 'lock_id': '**REDACTED**', 'lock_name': 'Lock-1A2B', @@ -18,6 +19,7 @@ 'duration_pullspring': 0, 'is_charging': False, 'is_connected': True, + 'is_enabled_auto_pullspring': False, 'is_enabled_pullspring': 0, 'lock_id': '**REDACTED**', 'lock_name': 'Lock-2C3D', diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index 432c3ebd19f..a568a7dcd82 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -41,6 +41,7 @@ 'original_name': None, 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '98765-lock', @@ -108,6 +109,7 @@ 'original_name': None, 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '12345-lock', @@ -156,6 +158,7 @@ 'original_name': None, 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '98765-lock', diff --git a/tests/components/tedee/snapshots/test_sensor.ambr b/tests/components/tedee/snapshots/test_sensor.ambr index 22679c4153a..dd34c8bdac4 100644 --- a/tests/components/tedee/snapshots/test_sensor.ambr +++ b/tests/components/tedee/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Battery', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345-battery_sensor', @@ -75,12 +76,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pullspring duration', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pullspring_duration', 'unique_id': '12345-pullspring_duration', @@ -133,6 +138,7 @@ 'original_name': 'Battery', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '98765-battery_sensor', @@ -179,12 +185,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pullspring duration', 'platform': 'tedee', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pullspring_duration', 'unique_id': '98765-pullspring_duration', diff --git a/tests/components/tedee/test_binary_sensor.py b/tests/components/tedee/test_binary_sensor.py index ccfd12440ea..cc931bb0c7c 100644 --- a/tests/components/tedee/test_binary_sensor.py +++ b/tests/components/tedee/test_binary_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tedee/test_diagnostics.py b/tests/components/tedee/test_diagnostics.py index 1487645572f..2cb18407432 100644 --- a/tests/components/tedee/test_diagnostics.py +++ b/tests/components/tedee/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Tedee integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index 71bf5262f00..7f1f52c7977 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -11,7 +11,7 @@ from aiotedee.exception import ( TedeeWebhookException, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN from homeassistant.components.webhook import async_generate_url diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py index 3c03d340100..4c8a3775443 100644 --- a/tests/components/tedee/test_sensor.py +++ b/tests/components/tedee/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 2a99e00a9ce..f9820243600 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -1,5 +1,7 @@ """The tests for the Template alarm control panel platform.""" +from typing import Any + import pytest from syrupy.assertion import SnapshotAssertion @@ -13,6 +15,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, + STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -20,10 +23,13 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component +from .conftest import ConfigurationStyle + from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache -TEMPLATE_NAME = "alarm_control_panel.test_template_panel" -PANEL_NAME = "alarm_control_panel.test" +TEST_OBJECT_ID = "test_template_panel" +TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "alarm_control_panel.test" @pytest.fixture @@ -93,50 +99,295 @@ EMPTY_ACTIONS = { } +UNIQUE_ID_CONFIG = { + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "unique_id": "not-so-unique-anymore", +} + + TEMPLATE_ALARM_CONFIG = { "value_template": "{{ states('alarm_control_panel.test') }}", **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, } -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, panel_config: dict[str, Any] +) -> None: + """Do setup of alarm control panel integration via legacy format.""" + config = {"alarm_control_panel": {"platform": "template", "panels": panel_config}} + + with assert_setup_component(count, ALARM_DOMAIN): + assert await async_setup_component( + hass, + ALARM_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, panel_config: dict[str, Any] +) -> None: + """Do setup of alarm control panel integration via modern format.""" + config = {"template": {"alarm_control_panel": panel_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() + + +@pytest.fixture +async def setup_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + panel_config: dict[str, Any], +) -> None: + """Do setup of alarm control panel integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, panel_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, panel_config) + + +async def async_setup_state_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of alarm control panel integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + "value_template": state_template, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + }, + ) + + +@pytest.fixture +async def setup_state_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of alarm control panel integration using a state template.""" + await async_setup_state_panel(hass, count, style, state_template) + + +@pytest.fixture +async def setup_base_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str | None, + panel_config: str, +): + """Do setup of alarm control panel integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + extra = {"value_template": state_template} if state_template else {} + await async_setup_legacy_format( + hass, + count, + {TEST_OBJECT_ID: {**extra, **panel_config}}, + ) + elif style == ConfigurationStyle.MODERN: + extra = {"state": state_template} if state_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra, + **panel_config, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_state_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of alarm control panel 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: { + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "value_template": state_template, + **extra, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "state": state_template, + **extra, + }, + ) + + @pytest.mark.parametrize( - "config", - [ - { - "alarm_control_panel": { - "platform": "template", - "panels": {"test_template_panel": TEMPLATE_ALARM_CONFIG}, - } - }, - ], + ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_panel") async def test_template_state_text(hass: HomeAssistant) -> None: """Test the state text of a template.""" for set_state in ( - AlarmControlPanelState.ARMED_HOME, AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + AlarmControlPanelState.ARMED_HOME, AlarmControlPanelState.ARMED_NIGHT, AlarmControlPanelState.ARMED_VACATION, - AlarmControlPanelState.ARMED_CUSTOM_BYPASS, AlarmControlPanelState.ARMING, AlarmControlPanelState.DISARMED, + AlarmControlPanelState.DISARMING, AlarmControlPanelState.PENDING, AlarmControlPanelState.TRIGGERED, ): - hass.states.async_set(PANEL_NAME, set_state) + hass.states.async_set(TEST_STATE_ENTITY_ID, set_state) await hass.async_block_till_done() - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(TEST_ENTITY_ID) assert state.state == set_state - hass.states.async_set(PANEL_NAME, "invalid_state") + hass.states.async_set(TEST_STATE_ENTITY_ID, "invalid_state") await hass.async_block_till_done() - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(TEST_ENTITY_ID) assert state.state == "unknown" +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("state_template", "expected"), + [ + ("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED), + ("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME), + ("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY), + ("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT), + ("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION), + ("{{ 'armed_custom_bypass' }}", AlarmControlPanelState.ARMED_CUSTOM_BYPASS), + ("{{ 'pending' }}", AlarmControlPanelState.PENDING), + ("{{ 'arming' }}", AlarmControlPanelState.ARMING), + ("{{ 'disarming' }}", AlarmControlPanelState.DISARMING), + ("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED), + ("{{ x - 1 }}", STATE_UNKNOWN), + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_panel") +async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: + """Test the state template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ 'disarmed' }}", + "{% if states.switch.test_state.state %}mdi:check{% endif %}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "icon"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_panel") +async def test_icon_template( + hass: HomeAssistant, +) -> None: + """Test icon template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") in ("", None) + + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:check" + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ 'disarmed' }}", + "{% if states.switch.test_state.state %}local/panel.png{% endif %}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "picture"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_panel") +async def test_picture_template( + hass: HomeAssistant, +) -> None: + """Test icon template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") in ("", None) + + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["entity_picture"] == "local/panel.png" + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion ) -> None: @@ -172,29 +423,18 @@ async def test_setup_config_entry( assert state.state == AlarmControlPanelState.DISARMED -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) +@pytest.mark.parametrize(("count", "state_template"), [(1, None)]) @pytest.mark.parametrize( - "config", - [ - { - "alarm_control_panel": { - "platform": "template", - "panels": {"test_template_panel": OPTIMISTIC_TEMPLATE_ALARM_CONFIG}, - } - }, - { - "alarm_control_panel": { - "platform": "template", - "panels": {"test_template_panel": EMPTY_ACTIONS}, - } - }, - ], + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "panel_config", [OPTIMISTIC_TEMPLATE_ALARM_CONFIG, EMPTY_ACTIONS] +) +@pytest.mark.usefixtures("setup_base_panel") async def test_optimistic_states(hass: HomeAssistant) -> None: """Test the optimistic state.""" - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(TEST_ENTITY_ID) await hass.async_block_till_done() assert state.state == "unknown" @@ -210,31 +450,45 @@ async def test_optimistic_states(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, service, - {"entity_id": TEMPLATE_NAME, "code": "1234"}, + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, blocking=True, ) await hass.async_block_till_done() - assert hass.states.get(TEMPLATE_NAME).state == set_state + assert hass.states.get(TEST_ENTITY_ID).state == set_state + + +@pytest.mark.parametrize("count", [0]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("panel_config", "state_template", "msg"), + [ + ( + OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "{% if blah %}", + "invalid template", + ), + ( + {"code_format": "bad_format", **OPTIMISTIC_TEMPLATE_ALARM_CONFIG}, + "disarmed", + "value must be one of ['no_code', 'number', 'text']", + ), + ], +) +@pytest.mark.usefixtures("setup_base_panel") +async def test_template_syntax_error( + hass: HomeAssistant, msg, caplog_setup_text +) -> None: + """Test templating syntax error.""" + assert len(hass.states.async_all("alarm_control_panel")) == 0 + assert (msg) in caplog_setup_text @pytest.mark.parametrize(("count", "domain"), [(0, "alarm_control_panel")]) @pytest.mark.parametrize( ("config", "msg"), [ - ( - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{% if blah %}", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - } - }, - } - }, - "invalid template", - ), ( { "alarm_control_panel": { @@ -264,25 +518,10 @@ async def test_optimistic_states(hass: HomeAssistant) -> None: }, "required key 'panels' not provided", ), - ( - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - "code_format": "bad_format", - } - }, - } - }, - "value must be one of ['no_code', 'number', 'text']", - ), ], ) @pytest.mark.usefixtures("start_ha") -async def test_template_syntax_error( +async def test_legacy_template_syntax_error( hass: HomeAssistant, msg, caplog_setup_text ) -> None: """Test templating syntax error.""" @@ -290,43 +529,30 @@ async def test_template_syntax_error( assert (msg) in caplog_setup_text -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute", "attribute_template"), + [(1, "disarmed", "name", '{{ "Template Alarm Panel" }}')], +) +@pytest.mark.parametrize( + ("style", "test_entity_id"), [ - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "name": '{{ "Template Alarm Panel" }}', - "value_template": "disarmed", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - } - }, - } - }, + (ConfigurationStyle.LEGACY, TEST_ENTITY_ID), + (ConfigurationStyle.MODERN, "alarm_control_panel.template_alarm_panel"), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_name(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("setup_single_attribute_state_panel") +async def test_name(hass: HomeAssistant, test_entity_id: str) -> None: """Test the accessibility of the name attribute.""" - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(test_entity_id) assert state is not None assert state.attributes.get("friendly_name") == "Template Alarm Panel" -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) @pytest.mark.parametrize( - "config", - [ - { - "alarm_control_panel": { - "platform": "template", - "panels": {"test_template_panel": TEMPLATE_ALARM_CONFIG}, - } - }, - ], + ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.parametrize( "service", @@ -340,7 +566,7 @@ async def test_name(hass: HomeAssistant) -> None: "alarm_trigger", ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_panel") async def test_actions( hass: HomeAssistant, service, call_service_events: list[Event] ) -> None: @@ -348,128 +574,147 @@ async def test_actions( await hass.services.async_call( ALARM_DOMAIN, service, - {"entity_id": TEMPLATE_NAME, "code": "1234"}, + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, blocking=True, ) await hass.async_block_till_done() assert len(call_service_events) == 1 assert call_service_events[0].data["service"] == service - assert call_service_events[0].data["service_data"]["code"] == TEMPLATE_NAME + assert call_service_events[0].data["service_data"]["code"] == TEST_ENTITY_ID -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("panel_config", "style"), [ - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_alarm_control_panel_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_alarm_control_panel_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, + ( + { + "test_template_alarm_control_panel_01": { + "value_template": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + "test_template_alarm_control_panel_02": { + "value_template": "{{ false }}", + **UNIQUE_ID_CONFIG, }, }, - }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_alarm_control_panel_01", + "state": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_alarm_control_panel_02", + "state": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_panel") async def test_unique_id(hass: HomeAssistant) -> None: """Test unique_id option only creates one alarm control panel per id.""" assert len(hass.states.async_all()) == 1 -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to alarm_control_panel unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "alarm_control_panel": [ + { + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "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("alarm_control_panel")) == 2 + + entry = entity_registry.async_get("alarm_control_panel.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("alarm_control_panel.test_b") + assert entry + assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "disarmed")]) @pytest.mark.parametrize( - ("config", "code_format", "code_arm_required"), + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("panel_config", "code_format", "code_arm_required"), [ ( - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - } - }, - } - }, + {}, "number", True, ), ( - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - "code_format": "text", - } - }, - } - }, + {"code_format": "text"}, "text", True, ), ( { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - "code_format": "no_code", - "code_arm_required": False, - } - }, - } + "code_format": "no_code", + "code_arm_required": False, }, None, False, ), ( { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - "code_format": "text", - "code_arm_required": False, - } - }, - } + "code_format": "text", + "code_arm_required": False, }, "text", False, ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_base_panel") async def test_code_config(hass: HomeAssistant, code_format, code_arm_required) -> None: """Test configuration options related to alarm code.""" - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("code_format") == code_format assert state.attributes.get("code_arm_required") == code_arm_required -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) @pytest.mark.parametrize( - "config", - [ - { - "alarm_control_panel": { - "platform": "template", - "panels": {"test_template_panel": TEMPLATE_ALARM_CONFIG}, - } - }, - ], + ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.parametrize( ("restored_state", "initial_state"), @@ -508,11 +753,11 @@ async def test_code_config(hass: HomeAssistant, code_format, code_arm_required) ) async def test_restore_state( hass: HomeAssistant, - count, - domain, - config, - restored_state, - initial_state, + count: int, + state_template: str, + style: ConfigurationStyle, + restored_state: str, + initial_state: str, ) -> None: """Test restoring template alarm control panel.""" @@ -522,17 +767,7 @@ async def test_restore_state( {}, ) mock_restore_cache(hass, (fake_state,)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) - - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() + await async_setup_state_panel(hass, count, style, state_template) state = hass.states.get("alarm_control_panel.test_template_panel") assert state.state == initial_state diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index a7ee953bb09..122801e6c59 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1225,6 +1225,62 @@ async def test_template_with_trigger_templated_delay_on(hass: HomeAssistant) -> assert state.state == STATE_OFF +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + ("config", "delay_state"), + [ + ( + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.data.beer == 2 }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 10 }) }}', + }, + }, + }, + STATE_ON, + ), + ( + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.data.beer != 2 }}", + "device_class": "motion", + "delay_off": '{{ ({ "seconds": 10 }) }}', + }, + }, + }, + STATE_OFF, + ), + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_trigger_template_delay_with_multiple_triggers( + hass: HomeAssistant, delay_state: str +) -> None: + """Test trigger based binary sensor with multiple triggers occurring during the delay.""" + future = dt_util.utcnow() + for _ in range(10): + # State should still be unknown + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}, context=Context()) + await hass.async_block_till_done() + + future += timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test") + assert state.state == delay_state + + @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( "config", diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 5f28a977867..48f45d879cd 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -40,6 +40,22 @@ TEST_OBJECT_ID = "test_template_cover" TEST_ENTITY_ID = f"cover.{TEST_OBJECT_ID}" TEST_STATE_ENTITY_ID = "cover.test_state" +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [ + "cover.test_state", + "cover.test_position", + "binary_sensor.garage_door_sensor", + ], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity}}"}} + ], +} + + OPEN_COVER = { "service": "test.automation", "data_template": { @@ -123,6 +139,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, cover_config: dict[str, Any] +) -> None: + """Do setup of cover integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "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() + + async def async_setup_cover_config( hass: HomeAssistant, count: int, @@ -134,6 +168,8 @@ async def async_setup_cover_config( await async_setup_legacy_format(hass, count, cover_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, cover_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, cover_config) @pytest.fixture @@ -175,6 +211,15 @@ async def setup_state_cover( "state": state_template, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_COVER_ACTIONS, + "state": state_template, + }, + ) @pytest.fixture @@ -205,6 +250,15 @@ async def setup_position_cover( "position": position_template, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_COVER_ACTIONS, + "position": position_template, + }, + ) @pytest.fixture @@ -240,13 +294,57 @@ async def setup_single_attribute_state_cover( **extra, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_COVER_ACTIONS, + "state": state_template, + **extra, + }, + ) + + +@pytest.fixture +async def setup_empty_action( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + script: str, +): + """Do setup of cover integration using a empty actions template.""" + empty = { + "open_cover": [], + "close_cover": [], + script: [], + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + {TEST_OBJECT_ID: empty}, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {"name": TEST_OBJECT_ID, **empty}, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {"name": TEST_OBJECT_ID, **empty}, + ) @pytest.mark.parametrize( ("count", "state_template"), [(1, "{{ states.cover.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("set_state", "test_state", "text"), @@ -260,13 +358,13 @@ async def setup_single_attribute_state_cover( ("bear", STATE_UNKNOWN, "Received invalid cover is_on state: bear"), ], ) +@pytest.mark.usefixtures("setup_state_cover") 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) @@ -280,6 +378,36 @@ async def test_template_state_text( assert text in caplog.text +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("state_template", "expected"), + [ + ("{{ 'open' }}", CoverState.OPEN), + ("{{ 'closed' }}", CoverState.CLOSED), + ("{{ 'opening' }}", CoverState.OPENING), + ("{{ 'closing' }}", CoverState.CLOSING), + ("{{ 'dog' }}", STATE_UNKNOWN), + ("{{ x - 1 }}", STATE_UNAVAILABLE), + ], +) +@pytest.mark.usefixtures("setup_state_cover") +async def test_template_state_states( + hass: HomeAssistant, + expected: str, +) -> None: + """Test state template states.""" + + hass.states.async_set(TEST_STATE_ENTITY_ID, None) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + @pytest.mark.parametrize( ("count", "state_template", "attribute_template"), [ @@ -295,6 +423,7 @@ async def test_template_state_text( [ (ConfigurationStyle.LEGACY, "position_template"), (ConfigurationStyle.MODERN, "position"), + (ConfigurationStyle.TRIGGER, "position"), ], ) @pytest.mark.parametrize( @@ -332,11 +461,11 @@ async def test_template_state_text( ) ], ) +@pytest.mark.usefixtures("setup_single_attribute_state_cover") 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) @@ -361,7 +490,7 @@ async def test_template_state_text_with_position( ( 1, "{{ states.cover.test_state.state }}", - "{{ states.cover.test_position.attributes.position }}", + "{{ state_attr('cover.test_state', 'position') }}", ) ], ) @@ -370,6 +499,7 @@ async def test_template_state_text_with_position( [ (ConfigurationStyle.LEGACY, "position_template"), (ConfigurationStyle.MODERN, "position"), + (ConfigurationStyle.TRIGGER, "position"), ], ) @pytest.mark.parametrize( @@ -379,11 +509,10 @@ async def test_template_state_text_with_position( None, ], ) +@pytest.mark.usefixtures("setup_single_attribute_state_cover") 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) @@ -393,15 +522,20 @@ async def test_template_state_text_ignored_if_none_or_empty( 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] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_template_state_boolean(hass: HomeAssistant, setup_state_cover) -> None: +@pytest.mark.usefixtures("setup_state_cover") +async def test_template_state_boolean(hass: HomeAssistant) -> None: """Test the value_template attribute.""" + # This forces a trigger for trigger based entities + hass.states.async_set(TEST_STATE_ENTITY_ID, None) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) assert state.state == CoverState.OPEN @@ -411,7 +545,8 @@ async def test_template_state_boolean(hass: HomeAssistant, setup_state_cover) -> [(1, "{{ states.cover.test_state.attributes.position }}")], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("test_state", "position", "expected"), @@ -421,13 +556,13 @@ async def test_template_state_boolean(hass: HomeAssistant, setup_state_cover) -> (CoverState.CLOSED, None, STATE_UNKNOWN), ], ) +@pytest.mark.usefixtures("setup_position_cover") 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) @@ -464,9 +599,17 @@ async def test_template_position( "optimistic": False, }, ), + ( + ConfigurationStyle.TRIGGER, + { + **NAMED_COVER_ACTIONS, + "optimistic": False, + }, + ), ], ) -async def test_template_not_optimistic(hass: HomeAssistant, setup_cover) -> None: +@pytest.mark.usefixtures("setup_cover") +async def test_template_not_optimistic(hass: HomeAssistant) -> None: """Test the is_closed attribute.""" state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN @@ -484,6 +627,10 @@ async def test_template_not_optimistic(hass: HomeAssistant, setup_cover) -> None ConfigurationStyle.MODERN, "tilt", ), + ( + ConfigurationStyle.TRIGGER, + "tilt", + ), ], ) @pytest.mark.parametrize( @@ -498,10 +645,13 @@ async def test_template_not_optimistic(hass: HomeAssistant, setup_cover) -> None ("{{ 'on' }}", None), ], ) -async def test_template_tilt( - hass: HomeAssistant, tilt_position: float | None, setup_single_attribute_state_cover -) -> None: +@pytest.mark.usefixtures("setup_single_attribute_state_cover") +async def test_template_tilt(hass: HomeAssistant, tilt_position: float | None) -> None: """Test tilt in and out-of-bound conditions.""" + # This forces a trigger for trigger based entities + hass.states.async_set(TEST_STATE_ENTITY_ID, None) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_tilt_position") == tilt_position @@ -518,6 +668,10 @@ async def test_template_tilt( ConfigurationStyle.MODERN, "position", ), + ( + ConfigurationStyle.TRIGGER, + "position", + ), ], ) @pytest.mark.parametrize( @@ -529,10 +683,13 @@ async def test_template_tilt( "{{ 'off' }}", ], ) -async def test_position_out_of_bounds( - hass: HomeAssistant, setup_single_attribute_state_cover -) -> None: +@pytest.mark.usefixtures("setup_single_attribute_state_cover") +async def test_position_out_of_bounds(hass: HomeAssistant) -> None: """Test position out-of-bounds condition.""" + # This forces a trigger for trigger based entities + hass.states.async_set(TEST_STATE_ENTITY_ID, None) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") is None @@ -577,6 +734,23 @@ async def test_position_out_of_bounds( }, "Invalid config for 'template': some but not all values in the same group of inclusion 'open_or_close'", ), + ( + ConfigurationStyle.TRIGGER, + { + "name": TEST_OBJECT_ID, + "state": "{{ 1 == 1 }}", + }, + "Invalid config for 'template': must contain at least one of open_cover, set_cover_position.", + ), + ( + ConfigurationStyle.TRIGGER, + { + "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( @@ -598,12 +772,17 @@ async def test_template_open_or_position( [(1, "{{ 0 }}")], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_open_action( - hass: HomeAssistant, setup_position_cover, calls: list[ServiceCall] -) -> None: +@pytest.mark.usefixtures("setup_position_cover") +async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the open_cover command.""" + + # This forces a trigger for trigger based entities + hass.states.async_set(TEST_STATE_ENTITY_ID, None) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) assert state.state == CoverState.CLOSED @@ -654,12 +833,29 @@ async def test_open_action( }, }, ), + ( + ConfigurationStyle.TRIGGER, + { + **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: +@pytest.mark.usefixtures("setup_cover") +async def test_close_stop_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the close-cover and stop_cover commands.""" + # This forces a trigger for trigger based entities + hass.states.async_set(TEST_STATE_ENTITY_ID, None) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) assert state.state == CoverState.OPEN @@ -705,11 +901,17 @@ async def test_close_stop_action( "set_cover_position": SET_COVER_POSITION, }, ), + ( + ConfigurationStyle.TRIGGER, + { + "name": TEST_OBJECT_ID, + "set_cover_position": SET_COVER_POSITION, + }, + ), ], ) -async def test_set_position( - hass: HomeAssistant, setup_cover, calls: list[ServiceCall] -) -> None: +@pytest.mark.usefixtures("setup_cover") +async def test_set_position(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the set_position command.""" state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN @@ -799,6 +1001,13 @@ async def test_set_position( "set_cover_tilt_position": SET_COVER_TILT_POSITION, }, ), + ( + ConfigurationStyle.TRIGGER, + { + **NAMED_COVER_ACTIONS, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + }, + ), ], ) @pytest.mark.parametrize( @@ -813,12 +1022,12 @@ async def test_set_position( (SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, 0), ], ) +@pytest.mark.usefixtures("setup_cover") async def test_set_tilt_position( hass: HomeAssistant, service, attr, tilt_position, - setup_cover, calls: list[ServiceCall], ) -> None: """Test the set_tilt_position command.""" @@ -855,10 +1064,18 @@ async def test_set_tilt_position( "set_cover_position": SET_COVER_POSITION, }, ), + ( + ConfigurationStyle.TRIGGER, + { + "name": TEST_OBJECT_ID, + "set_cover_position": SET_COVER_POSITION, + }, + ), ], ) +@pytest.mark.usefixtures("setup_cover") async def test_set_position_optimistic( - hass: HomeAssistant, setup_cover, calls: list[ServiceCall] + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test optimistic position mode.""" state = hass.states.get(TEST_ENTITY_ID) @@ -888,6 +1105,50 @@ async def test_set_position_optimistic( assert state.state == test_state +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "cover_config"), + [ + ( + ConfigurationStyle.TRIGGER, + { + "name": TEST_OBJECT_ID, + "set_cover_position": SET_COVER_POSITION, + "picture": "{{ 'foo.png' if is_state('cover.test_state', 'open') else 'bar.png' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_cover") +async def test_non_optimistic_template_with_optimistic_state( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test optimistic state with non-optimistic template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert "entity_picture" not in state.attributes + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_POSITION: 42}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == CoverState.OPEN + assert state.attributes["current_position"] == 42.0 + assert "entity_picture" not in state.attributes + + hass.states.async_set(TEST_STATE_ENTITY_ID, CoverState.OPEN) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == CoverState.OPEN + assert state.attributes["current_position"] == 42.0 + assert state.attributes["entity_picture"] == "foo.png" + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("style", "cover_config"), @@ -911,10 +1172,20 @@ async def test_set_position_optimistic( "set_cover_tilt_position": SET_COVER_TILT_POSITION, }, ), + ( + ConfigurationStyle.TRIGGER, + { + "name": TEST_OBJECT_ID, + "position": "{{ 100 }}", + "set_cover_position": SET_COVER_POSITION, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + }, + ), ], ) +@pytest.mark.usefixtures("setup_cover") async def test_set_tilt_position_optimistic( - hass: HomeAssistant, setup_cover, calls: list[ServiceCall] + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test the optimistic tilt_position mode.""" state = hass.states.get(TEST_ENTITY_ID) @@ -955,18 +1226,20 @@ async def test_set_tilt_position_optimistic( ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "attribute", "initial_expected_state"), [ - (ConfigurationStyle.LEGACY, "icon_template"), - (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.LEGACY, "icon_template", ""), + (ConfigurationStyle.MODERN, "icon", ""), + (ConfigurationStyle.TRIGGER, "icon", None), ], ) +@pytest.mark.usefixtures("setup_single_attribute_state_cover") async def test_icon_template( - hass: HomeAssistant, setup_single_attribute_state_cover + hass: HomeAssistant, initial_expected_state: str | None ) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") == "" + assert state.attributes.get("icon") == initial_expected_state state = hass.states.async_set("cover.test_state", CoverState.OPEN) await hass.async_block_till_done() @@ -987,18 +1260,20 @@ async def test_icon_template( ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "attribute", "initial_expected_state"), [ - (ConfigurationStyle.LEGACY, "entity_picture_template"), - (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.LEGACY, "entity_picture_template", ""), + (ConfigurationStyle.MODERN, "picture", ""), + (ConfigurationStyle.TRIGGER, "picture", None), ], ) +@pytest.mark.usefixtures("setup_single_attribute_state_cover") async def test_entity_picture_template( - hass: HomeAssistant, setup_single_attribute_state_cover + hass: HomeAssistant, initial_expected_state: str | None ) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") == "" + assert state.attributes.get("entity_picture") == initial_expected_state state = hass.states.async_set("cover.test_state", CoverState.OPEN) await hass.async_block_till_done() @@ -1023,18 +1298,22 @@ async def test_entity_picture_template( [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) -async def test_availability_template( - hass: HomeAssistant, setup_single_attribute_state_cover -) -> None: +@pytest.mark.usefixtures("setup_single_attribute_state_cover") +async def test_availability_template(hass: HomeAssistant) -> None: """Test availability template.""" hass.states.async_set("availability_state.state", STATE_OFF) + # This forces a trigger for trigger based entities + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE hass.states.async_set("availability_state.state", STATE_ON) + # This forces a trigger for trigger based entities + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE @@ -1071,15 +1350,35 @@ async def test_availability_template( }, template.DOMAIN, ), + ( + { + "template": { + **TEST_STATE_TRIGGER, + "cover": { + **NAMED_COVER_ACTIONS, + "state": "{{ true }}", + "availability": "{{ x - 12 }}", + }, + } + }, + template.DOMAIN, + ), ], ) @pytest.mark.usefixtures("start_ha") async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, caplog_setup_text ) -> None: """Test that an invalid availability keeps the device available.""" + + # This forces a trigger for trigger based entities + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert hass.states.get(TEST_ENTITY_ID) != STATE_UNAVAILABLE - assert "UndefinedError: 'x' is undefined" in caplog_setup_text + + err = "UndefinedError: 'x' is undefined" + assert err in caplog_setup_text or err in caplog.text @pytest.mark.parametrize( @@ -1088,11 +1387,10 @@ async def test_invalid_availability_template_keeps_component_available( ) @pytest.mark.parametrize( "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_device_class( - hass: HomeAssistant, setup_single_attribute_state_cover -) -> None: +@pytest.mark.usefixtures("setup_single_attribute_state_cover") +async def test_device_class(hass: HomeAssistant) -> None: """Test device class.""" state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("device_class") == "door" @@ -1104,11 +1402,10 @@ async def test_device_class( ) @pytest.mark.parametrize( "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_invalid_device_class( - hass: HomeAssistant, setup_single_attribute_state_cover -) -> None: +@pytest.mark.usefixtures("setup_single_attribute_state_cover") +async def test_invalid_device_class(hass: HomeAssistant) -> None: """Test device class.""" state = hass.states.get(TEST_ENTITY_ID) assert not state @@ -1138,9 +1435,23 @@ async def test_invalid_device_class( ], ConfigurationStyle.MODERN, ), + ( + [ + { + "name": "test_template_cover_01", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_cover_02", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) -async def test_unique_id(hass: HomeAssistant, setup_cover) -> None: +@pytest.mark.usefixtures("setup_cover") +async def test_unique_id(hass: HomeAssistant) -> None: """Test unique_id option only creates one cover per id.""" assert len(hass.states.async_all()) == 1 @@ -1211,9 +1522,18 @@ async def test_nested_unique_id( "state": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "name": "Garage Door", + **COVER_ACTIONS, + "state": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}", + }, + ), ], ) -async def test_state_gets_lowercased(hass: HomeAssistant, setup_cover) -> None: +@pytest.mark.usefixtures("setup_cover") +async def test_state_gets_lowercased(hass: HomeAssistant) -> None: """Test True/False is lowercased.""" hass.states.async_set("binary_sensor.garage_door_sensor", "off") @@ -1242,12 +1562,12 @@ async def test_state_gets_lowercased(hass: HomeAssistant, setup_cover) -> None: [ (ConfigurationStyle.LEGACY, "icon_template"), (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.TRIGGER, "icon"), ], ) +@pytest.mark.usefixtures("setup_single_attribute_state_cover") async def test_self_referencing_icon_with_no_template_is_not_a_loop( - hass: HomeAssistant, - setup_single_attribute_state_cover, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test a self referencing icon with no value template is not a loop.""" assert len(hass.states.async_all()) == 1 @@ -1255,6 +1575,11 @@ async def test_self_referencing_icon_with_no_template_is_not_a_loop( assert "Template loop detected" not in caplog.text +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) @pytest.mark.parametrize( ("script", "supported_feature"), [ @@ -1269,32 +1594,11 @@ async def test_self_referencing_icon_with_no_template_is_not_a_loop( ), ], ) -async def test_emtpy_action_config( - hass: HomeAssistant, script: str, supported_feature: CoverEntityFeature +@pytest.mark.usefixtures("setup_empty_action") +async def test_empty_action_config( + hass: HomeAssistant, supported_feature: CoverEntityFeature ) -> None: """Test configuration with empty script.""" - with assert_setup_component(1, COVER_DOMAIN): - assert await async_setup_component( - hass, - COVER_DOMAIN, - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - "open_cover": [], - "close_cover": [], - script: [], - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert ( state.attributes["supported_features"] diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index dac97931fa7..a061ce86256 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -5,8 +5,7 @@ from typing import Any import pytest import voluptuous as vol -from homeassistant import setup -from homeassistant.components import fan +from homeassistant.components import fan, template from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, @@ -14,12 +13,12 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, DIRECTION_FORWARD, DIRECTION_REVERSE, - DOMAIN as FAN_DOMAIN, FanEntityFeature, NotValidPresetModeError, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE 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 @@ -27,23 +26,14 @@ from .conftest import ConfigurationStyle from tests.common import assert_setup_component from tests.components.fan import common -_TEST_OBJECT_ID = "test_fan" -_TEST_FAN = f"fan.{_TEST_OBJECT_ID}" +TEST_OBJECT_ID = "test_fan" +TEST_ENTITY_ID = f"fan.{TEST_OBJECT_ID}" # Represent for fan's state _STATE_INPUT_BOOLEAN = "input_boolean.state" # Represent for fan's state _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" -# Represent for fan's preset mode -_PRESET_MODE_INPUT_SELECT = "input_select.preset_mode" -# Represent for fan's speed percentage -_PERCENTAGE_INPUT_NUMBER = "input_number.percentage" -# Represent for fan's oscillating -_OSC_INPUT = "input_select.osc" -# Represent for fan's direction -_DIRECTION_INPUT_SELECT = "input_select.direction" - -OPTIMISTIC_ON_OFF_CONFIG = { +OPTIMISTIC_ON_OFF_ACTIONS = { "turn_on": { "service": "test.automation", "data": { @@ -59,7 +49,10 @@ OPTIMISTIC_ON_OFF_CONFIG = { }, }, } - +NAMED_ON_OFF_ACTIONS = { + **OPTIMISTIC_ON_OFF_ACTIONS, + "name": TEST_OBJECT_ID, +} PERCENTAGE_ACTION = { "set_percentage": { @@ -72,7 +65,7 @@ PERCENTAGE_ACTION = { }, } OPTIMISTIC_PERCENTAGE_CONFIG = { - **OPTIMISTIC_ON_OFF_CONFIG, + **OPTIMISTIC_ON_OFF_ACTIONS, **PERCENTAGE_ACTION, } @@ -87,7 +80,7 @@ PRESET_MODE_ACTION = { }, } OPTIMISTIC_PRESET_MODE_CONFIG = { - **OPTIMISTIC_ON_OFF_CONFIG, + **OPTIMISTIC_ON_OFF_ACTIONS, **PRESET_MODE_ACTION, } OPTIMISTIC_PRESET_MODE_CONFIG2 = { @@ -106,7 +99,7 @@ OSCILLATE_ACTION = { }, } OPTIMISTIC_OSCILLATE_CONFIG = { - **OPTIMISTIC_ON_OFF_CONFIG, + **OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION, } @@ -121,16 +114,38 @@ DIRECTION_ACTION = { }, } OPTIMISTIC_DIRECTION_CONFIG = { - **OPTIMISTIC_ON_OFF_CONFIG, + **OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION, } +UNIQUE_ID_CONFIG = { + **OPTIMISTIC_ON_OFF_ACTIONS, + "unique_id": "not-so-unique-anymore", +} + + +def _verify( + hass: HomeAssistant, + expected_state: str, + 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_ENTITY_ID) + attributes = state.attributes + assert state.state == str(expected_state) + assert attributes.get(ATTR_PERCENTAGE) == expected_percentage + assert attributes.get(ATTR_OSCILLATING) == expected_oscillating + assert attributes.get(ATTR_DIRECTION) == expected_direction + assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode async def async_setup_legacy_format( - hass: HomeAssistant, count: int, light_config: dict[str, Any] + hass: HomeAssistant, count: int, fan_config: dict[str, Any] ) -> None: """Do setup of fan integration via legacy format.""" - config = {"fan": {"platform": "template", "fans": light_config}} + config = {"fan": {"platform": "template", "fans": fan_config}} with assert_setup_component(count, fan.DOMAIN): assert await async_setup_component( @@ -144,6 +159,38 @@ async def async_setup_legacy_format( await hass.async_block_till_done() +async def async_setup_modern_format( + hass: HomeAssistant, count: int, fan_config: dict[str, Any] +) -> None: + """Do setup of fan integration via modern format.""" + config = {"template": {"fan": fan_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_legacy_named_fan( + hass: HomeAssistant, count: int, fan_config: dict[str, Any] +): + """Do setup of a named fan via legacy format.""" + await async_setup_legacy_format(hass, count, {TEST_OBJECT_ID: fan_config}) + + +async def async_setup_modern_named_fan( + hass: HomeAssistant, count: int, fan_config: dict[str, Any] +): + """Do setup of a named fan via legacy format.""" + await async_setup_modern_format(hass, count, {"name": TEST_OBJECT_ID, **fan_config}) + + async def async_setup_legacy_format_with_attribute( hass: HomeAssistant, count: int, @@ -157,7 +204,7 @@ async def async_setup_legacy_format_with_attribute( hass, count, { - _TEST_OBJECT_ID: { + TEST_OBJECT_ID: { **extra_config, "value_template": "{{ 1 == 1 }}", **extra, @@ -166,16 +213,83 @@ async def async_setup_legacy_format_with_attribute( ) +async def async_setup_modern_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a modern fan that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra_config, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + @pytest.fixture async def setup_fan( hass: HomeAssistant, count: int, style: ConfigurationStyle, - light_config: dict[str, Any], + fan_config: dict[str, Any], ) -> None: """Do setup of fan integration.""" if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, light_config) + await async_setup_legacy_format(hass, count, fan_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, fan_config) + + +@pytest.fixture +async def setup_named_fan( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + fan_config: dict[str, Any], +) -> None: + """Do setup of fan integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_named_fan(hass, count, fan_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_named_fan(hass, count, fan_config) + + +@pytest.fixture +async def setup_state_fan( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of fan integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **OPTIMISTIC_ON_OFF_ACTIONS, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_ON_OFF_ACTIONS, + "state": state_template, + }, + ) @pytest.fixture @@ -187,9 +301,14 @@ async def setup_test_fan_with_extra_config( 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) + await async_setup_legacy_format( + hass, count, {TEST_OBJECT_ID: {**fan_config, **extra_config}} + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": TEST_OBJECT_ID, **fan_config, **extra_config} + ) @pytest.fixture @@ -204,344 +323,507 @@ async def setup_optimistic_fan_attribute( await async_setup_legacy_format_with_attribute( hass, count, "", "", extra_config ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format_with_attribute( + hass, count, "", "", extra_config + ) -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) +@pytest.fixture +async def setup_single_attribute_state_fan( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, + state_template: str, + extra_config: dict, +) -> None: + """Do setup of fan 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: { + **OPTIMISTIC_ON_OFF_ACTIONS, + "value_template": state_template, + **extra, + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_ON_OFF_ACTIONS, + "state": state_template, + **extra, + **extra_config, + }, + ) + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 'on' }}")]) @pytest.mark.parametrize( - "config", - [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ], + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_fan") async def test_missing_optional_config(hass: HomeAssistant) -> None: """Test: missing optional template is ok.""" _verify(hass, STATE_ON, None, None, None, None) -@pytest.mark.parametrize(("count", "domain"), [(0, FAN_DOMAIN)]) +@pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + "fan_config", [ { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "platform": "template", - "fans": { - "test_fan": { - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - }, - } + "value_template": "{{ 'on' }}", + "turn_off": {"service": "script.fan_off"}, }, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_off": {"service": "script.fan_off"}, - } - }, - }, - } - }, - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_on": {"service": "script.fan_on"}, - } - }, - }, - } + "value_template": "{{ 'on' }}", + "turn_on": {"service": "script.fan_on"}, }, ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_fan") async def test_wrong_template_config(hass: HomeAssistant) -> None: - """Test: missing 'value_template' will fail.""" + """Test: missing 'turn_on' or 'turn_off' will fail.""" assert hass.states.async_all("fan") == [] -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "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", - } - }, - } - }, - ], + ("count", "state_template"), [(1, "{{ is_state('input_boolean.state', 'on') }}")] ) -@pytest.mark.usefixtures("start_ha") -async def test_templates_with_entities(hass: HomeAssistant) -> None: - """Test tempalates with values from other entities.""" - _verify(hass, STATE_OFF, 0, None, None, None) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_fan") +async def test_state_template(hass: HomeAssistant) -> None: + """Test state template.""" + _verify(hass, STATE_OFF, None, None, None, None) - hass.states.async_set(_STATE_INPUT_BOOLEAN, True) - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 66) - hass.states.async_set(_OSC_INPUT, "True") - - for set_state, set_value, value in ( - (_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD, 66), - (_PERCENTAGE_INPUT_NUMBER, 33, 33), - (_PERCENTAGE_INPUT_NUMBER, 66, 66), - (_PERCENTAGE_INPUT_NUMBER, 100, 100), - (_PERCENTAGE_INPUT_NUMBER, "dog", 0), - ): - hass.states.async_set(set_state, set_value) - await hass.async_block_till_done() - _verify(hass, STATE_ON, value, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_STATE_INPUT_BOOLEAN, False) + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) await hass.async_block_till_done() - _verify(hass, STATE_OFF, 0, True, DIRECTION_FORWARD, None) + + _verify(hass, STATE_ON, None, None, None, None) + + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_OFF) + await hass.async_block_till_done() + + _verify(hass, STATE_OFF, None, None, None, None) -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "entity", "tests"), + ("state_template", "expected"), + [ + ("{{ True }}", STATE_ON), + ("{{ False }}", STATE_OFF), + ("{{ x - 1 }}", STATE_UNAVAILABLE), + ("{{ 7.45 }}", STATE_OFF), + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_fan") +async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: + """Test state template.""" + _verify(hass, expected, None, None, None, None) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), [ ( + 1, + "{{ 1 == 1}}", + "{% if states.input_boolean.state.state %}/local/switch.png{% endif %}", + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "picture"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_picture_template(hass: HomeAssistant) -> None: + """Test picture template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") in ("", None) + + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["entity_picture"] == "/local/switch.png" + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1}}", + "{% if states.input_boolean.state.state %}mdi:eye{% endif %}", + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "icon"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_icon_template(hass: HomeAssistant) -> None: + """Test icon template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") in ("", None) + + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:eye" + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1 }}", + "{{ states('sensor.percentage') }}", + PERCENTAGE_ACTION, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "percentage_template"), + (ConfigurationStyle.MODERN, "percentage"), + ], +) +@pytest.mark.parametrize( + ("percent", "expected"), + [ + ("0", 0), + ("33", 33), + ("invalid", 0), + ("5000", 0), + ("100", 100), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_percentage_template( + hass: HomeAssistant, percent: str, expected: int, calls: list[ServiceCall] +) -> None: + """Test templates with fan percentages from other entities.""" + hass.states.async_set("sensor.percentage", percent) + await hass.async_block_till_done() + _verify(hass, STATE_ON, expected, None, None, None) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1 }}", + "{{ states('sensor.preset_mode') }}", + {"preset_modes": ["auto", "smart"], **PRESET_MODE_ACTION}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "preset_mode_template"), + (ConfigurationStyle.MODERN, "preset_mode"), + ], +) +@pytest.mark.parametrize( + ("preset_mode", "expected"), + [ + ("0", None), + ("invalid", None), + ("auto", "auto"), + ("smart", "smart"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_preset_mode_template( + hass: HomeAssistant, preset_mode: str, expected: int +) -> None: + """Test preset_mode template.""" + hass.states.async_set("sensor.preset_mode", preset_mode) + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, None, None, expected) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1 }}", + "{{ is_state('binary_sensor.oscillating', 'on') }}", + OSCILLATE_ACTION, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "oscillating_template"), + (ConfigurationStyle.MODERN, "oscillating"), + ], +) +@pytest.mark.parametrize( + ("oscillating", "expected"), + [ + (STATE_ON, True), + (STATE_OFF, False), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_oscillating_template( + hass: HomeAssistant, oscillating: str, expected: bool | None +) -> None: + """Test oscillating template.""" + hass.states.async_set("binary_sensor.oscillating", oscillating) + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, expected, None, None) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1 }}", + "{{ states('sensor.direction') }}", + DIRECTION_ACTION, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "direction_template"), + (ConfigurationStyle.MODERN, "direction"), + ], +) +@pytest.mark.parametrize( + ("direction", "expected"), + [ + (DIRECTION_FORWARD, DIRECTION_FORWARD), + (DIRECTION_REVERSE, DIRECTION_REVERSE), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_direction_template( + hass: HomeAssistant, direction: str, expected: bool | None +) -> None: + """Test direction template.""" + hass.states.async_set("sensor.direction", direction) + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, None, expected, None) + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "percentage_template": "{{ states('sensor.percentage') }}", - **OPTIMISTIC_PERCENTAGE_CONFIG, - }, - }, - } + "availability_template": ( + "{{ is_state('availability_boolean.state', 'on') }}" + ), + "value_template": "{{ 'on' }}", + "oscillating_template": "{{ 1 == 1 }}", + "direction_template": "{{ 'forward' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, }, - "sensor.percentage", - [ - ("0", 0, None), - ("33", 33, None), - ("invalid", 0, None), - ("5000", 0, None), - ("100", 100, None), - ("0", 0, None), - ], ), ( + ConfigurationStyle.MODERN, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "preset_modes": ["auto", "smart"], - "preset_mode_template": ( - "{{ states('sensor.preset_mode') }}" - ), - **OPTIMISTIC_PRESET_MODE_CONFIG, - }, - }, - } + "availability": ("{{ is_state('availability_boolean.state', 'on') }}"), + "state": "{{ 'on' }}", + "oscillating": "{{ 1 == 1 }}", + "direction": "{{ 'forward' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, }, - "sensor.preset_mode", - [ - ("0", None, None), - ("invalid", None, None), - ("auto", None, "auto"), - ("smart", None, "smart"), - ("invalid", None, None), - ], ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_templates_with_entities2(hass: HomeAssistant, entity, tests) -> None: - """Test templates with values from other entities.""" - for set_percentage, test_percentage, test_type in tests: - hass.states.async_set(entity, set_percentage) - await hass.async_block_till_done() - _verify(hass, STATE_ON, test_percentage, None, None, test_type) - - -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "availability_template": ( - "{{ is_state('availability_boolean.state', 'on') }}" - ), - "value_template": "{{ 'on' }}", - "oscillating_template": "{{ 1 == 1 }}", - "direction_template": "{{ 'forward' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_named_fan") async def test_availability_template_with_entities(hass: HomeAssistant) -> None: """Test availability tempalates with values from other entities.""" for state, test_assert in ((STATE_ON, True), (STATE_OFF, False)): hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, state) await hass.async_block_till_done() - assert (hass.states.get(_TEST_FAN).state != STATE_UNAVAILABLE) == test_assert + assert ( + hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + ) == test_assert -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "states"), + ("style", "fan_config", "states"), [ ( + ConfigurationStyle.LEGACY, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'unavailable' }}", - **OPTIMISTIC_ON_OFF_CONFIG, - } - }, - } + "value_template": "{{ 'unavailable' }}", + **OPTIMISTIC_ON_OFF_ACTIONS, }, [STATE_OFF, None, None, None], ), ( + ConfigurationStyle.MODERN, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "percentage_template": "{{ 0 }}", - **OPTIMISTIC_PERCENTAGE_CONFIG, - "oscillating_template": "{{ 'unavailable' }}", - **OSCILLATE_ACTION, - "direction_template": "{{ 'unavailable' }}", - **DIRECTION_ACTION, - } - }, - } + "state": "{{ 'unavailable' }}", + **OPTIMISTIC_ON_OFF_ACTIONS, + }, + [STATE_OFF, None, None, None], + ), + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + "percentage_template": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 'unavailable' }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'unavailable' }}", + **DIRECTION_ACTION, }, [STATE_ON, 0, None, None], ), ( + ConfigurationStyle.MODERN, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "percentage_template": "{{ 66 }}", - **OPTIMISTIC_PERCENTAGE_CONFIG, - "oscillating_template": "{{ 1 == 1 }}", - **OSCILLATE_ACTION, - "direction_template": "{{ 'forward' }}", - **DIRECTION_ACTION, - } - }, - } + "state": "{{ 'on' }}", + "percentage": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 'unavailable' }}", + **OSCILLATE_ACTION, + "direction": "{{ 'unavailable' }}", + **DIRECTION_ACTION, + }, + [STATE_ON, 0, None, None], + ), + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + "percentage_template": "{{ 66 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 1 == 1 }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'forward' }}", + **DIRECTION_ACTION, }, [STATE_ON, 66, True, DIRECTION_FORWARD], ), ( + ConfigurationStyle.MODERN, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'abc' }}", - "percentage_template": "{{ 0 }}", - **OPTIMISTIC_PERCENTAGE_CONFIG, - "oscillating_template": "{{ 'xyz' }}", - **OSCILLATE_ACTION, - "direction_template": "{{ 'right' }}", - **DIRECTION_ACTION, - } - }, - } + "state": "{{ 'on' }}", + "percentage": "{{ 66 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 1 == 1 }}", + **OSCILLATE_ACTION, + "direction": "{{ 'forward' }}", + **DIRECTION_ACTION, + }, + [STATE_ON, 66, True, DIRECTION_FORWARD], + ), + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'abc' }}", + "percentage_template": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 'xyz' }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'right' }}", + **DIRECTION_ACTION, + }, + [STATE_OFF, 0, None, None], + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'abc' }}", + "percentage": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 'xyz' }}", + **OSCILLATE_ACTION, + "direction": "{{ 'right' }}", + **DIRECTION_ACTION, }, [STATE_OFF, 0, None, None], ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_named_fan") async def test_template_with_unavailable_entities(hass: HomeAssistant, states) -> None: """Test unavailability with value_template.""" _verify(hass, states[0], states[1], states[2], states[3], None) -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("style", "fan_config"), [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "availability_template": "{{ x - 12 }}", - "preset_mode_template": ( - "{{ states('input_select.preset_mode') }}" - ), - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + "availability_template": "{{ x - 12 }}", + "preset_mode_template": ("{{ states('input_select.preset_mode') }}"), + "oscillating_template": "{{ states('input_select.osc') }}", + "direction_template": "{{ states('input_select.direction') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + "availability": "{{ x - 12 }}", + "preset_mode": ("{{ states('input_select.preset_mode') }}"), + "oscillating": "{{ states('input_select.osc') }}", + "direction": "{{ states('input_select.direction') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_named_fan") async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text ) -> None: @@ -551,147 +833,380 @@ async def test_invalid_availability_template_keeps_component_available( assert "x" in caplog_setup_text +@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_ON_OFF_ACTIONS)]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'off' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'off' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_on_off(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test turn on and turn off.""" - await _register_components(hass) - for expected_calls, (func, state, action) in enumerate( + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + for expected_calls, (func, action) in enumerate( [ - (common.async_turn_on, STATE_ON, "turn_on"), - (common.async_turn_off, STATE_OFF, "turn_off"), + (common.async_turn_on, "turn_on"), + (common.async_turn_off, "turn_off"), ] ): - await func(hass, _TEST_FAN) - assert hass.states.get(_STATE_INPUT_BOOLEAN).state == state - _verify(hass, state, 0, None, None, None) + await func(hass, TEST_ENTITY_ID) + assert len(calls) == expected_calls + 1 assert calls[-1].data["action"] == action - assert calls[-1].data["caller"] == _TEST_FAN + assert calls[-1].data["caller"] == TEST_ENTITY_ID -async def test_set_invalid_direction_from_initial_stage( +@pytest.mark.parametrize( + ("count", "extra_config"), + [ + ( + 1, + { + **OPTIMISTIC_ON_OFF_ACTIONS, + **OPTIMISTIC_PRESET_MODE_CONFIG2, + **OPTIMISTIC_PERCENTAGE_CONFIG, + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'off' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'off' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +async def test_on_with_extra_attributes( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: + """Test turn on and turn off.""" + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await common.async_turn_on(hass, TEST_ENTITY_ID, 100) + + assert len(calls) == 2 + assert calls[-2].data["action"] == "turn_on" + assert calls[-2].data["caller"] == TEST_ENTITY_ID + + assert calls[-1].data["action"] == "set_percentage" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["percentage"] == 100 + + await common.async_turn_off(hass, TEST_ENTITY_ID) + + assert len(calls) == 3 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await common.async_turn_on(hass, TEST_ENTITY_ID, None, "auto") + + assert len(calls) == 5 + assert calls[-2].data["action"] == "turn_on" + assert calls[-2].data["caller"] == TEST_ENTITY_ID + + assert calls[-1].data["action"] == "set_preset_mode" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["preset_mode"] == "auto" + + await common.async_turn_off(hass, TEST_ENTITY_ID) + + assert len(calls) == 6 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await common.async_turn_on(hass, TEST_ENTITY_ID, 50, "high") + + assert len(calls) == 9 + assert calls[-3].data["action"] == "turn_on" + assert calls[-3].data["caller"] == TEST_ENTITY_ID + + assert calls[-2].data["action"] == "set_preset_mode" + assert calls[-2].data["caller"] == TEST_ENTITY_ID + assert calls[-2].data["preset_mode"] == "high" + + assert calls[-1].data["action"] == "set_percentage" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["percentage"] == 50 + + await common.async_turn_off(hass, TEST_ENTITY_ID) + + assert len(calls) == 10 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +async def test_set_invalid_direction_from_initial_stage(hass: HomeAssistant) -> None: """Test set invalid direction when fan is in initial state.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) - - await common.async_set_direction(hass, _TEST_FAN, "invalid") - - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == "" - _verify(hass, STATE_ON, 0, None, None, None) + await common.async_set_direction(hass, TEST_ENTITY_ID, "invalid") + _verify(hass, STATE_ON, None, None, None, None) +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set oscillating.""" - await _register_components(hass) expected_calls = 0 - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) expected_calls += 1 for state in (True, False): - await common.async_oscillate(hass, _TEST_FAN, state) - assert hass.states.get(_OSC_INPUT).state == str(state) - _verify(hass, STATE_ON, 0, state, None, None) + await common.async_oscillate(hass, TEST_ENTITY_ID, state) + _verify(hass, STATE_ON, None, state, None, None) expected_calls += 1 assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_oscillating" - assert calls[-1].data["caller"] == _TEST_FAN - assert calls[-1].data["option"] == state + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["oscillating"] == state +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_direction(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid direction.""" - await _register_components(hass) expected_calls = 0 - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) expected_calls += 1 - for cmd in (DIRECTION_FORWARD, DIRECTION_REVERSE): - await common.async_set_direction(hass, _TEST_FAN, cmd) - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == cmd - _verify(hass, STATE_ON, 0, None, cmd, None) + for direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): + await common.async_set_direction(hass, TEST_ENTITY_ID, direction) + _verify(hass, STATE_ON, None, None, direction, None) expected_calls += 1 assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_direction" - assert calls[-1].data["caller"] == _TEST_FAN - assert calls[-1].data["option"] == cmd + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["direction"] == direction +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_invalid_direction( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid direction when fan has valid direction.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) - for cmd in (DIRECTION_FORWARD, "invalid"): - await common.async_set_direction(hass, _TEST_FAN, cmd) - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, 0, None, DIRECTION_FORWARD, None) + expected_calls = 1 + for direction in (DIRECTION_FORWARD, "invalid"): + await common.async_set_direction(hass, TEST_ENTITY_ID, direction) + _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD, None) + assert len(calls) == expected_calls + assert calls[-1].data["action"] == "set_direction" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["direction"] == DIRECTION_FORWARD +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, OPTIMISTIC_PRESET_MODE_CONFIG2)] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_preset_modes(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test preset_modes.""" - await _register_components( - hass, ["off", "low", "medium", "high", "auto", "smart"], ["auto", "smart"] - ) - - await common.async_turn_on(hass, _TEST_FAN) - for extra, state, expected_calls in ( - ("auto", "auto", 2), - ("smart", "smart", 3), - ("invalid", "smart", 3), - ): - if extra != state: + expected_calls = 0 + valid_modes = OPTIMISTIC_PRESET_MODE_CONFIG2["preset_modes"] + for mode in ("auto", "low", "medium", "high", "invalid", "smart"): + if mode not in valid_modes: with pytest.raises(NotValidPresetModeError): - await common.async_set_preset_mode(hass, _TEST_FAN, extra) + await common.async_set_preset_mode(hass, TEST_ENTITY_ID, mode) else: - await common.async_set_preset_mode(hass, _TEST_FAN, extra) - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == state - assert len(calls) == expected_calls - assert calls[-1].data["action"] == "set_preset_mode" - assert calls[-1].data["caller"] == _TEST_FAN - assert calls[-1].data["option"] == state + await common.async_set_preset_mode(hass, TEST_ENTITY_ID, mode) + expected_calls += 1 - await common.async_turn_on(hass, _TEST_FAN, preset_mode="auto") - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" + assert len(calls) == expected_calls + assert calls[-1].data["action"] == "set_preset_mode" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["preset_mode"] == mode +@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_PERCENTAGE_CONFIG)]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_percentage(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid speed percentage.""" - await _register_components(hass) expected_calls = 0 - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) expected_calls += 1 for state, value in ( (STATE_ON, 100), (STATE_ON, 66), (STATE_ON, 0), ): - await common.async_set_percentage(hass, _TEST_FAN, value) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + await common.async_set_percentage(hass, TEST_ENTITY_ID, value) _verify(hass, state, value, None, None, None) expected_calls += 1 assert len(calls) == expected_calls - assert calls[-1].data["action"] == "set_value" - assert calls[-1].data["caller"] == _TEST_FAN - assert calls[-1].data["value"] == value + assert calls[-1].data["action"] == "set_percentage" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["percentage"] == value - await common.async_turn_on(hass, _TEST_FAN, percentage=50) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 50 + await common.async_turn_on(hass, TEST_ENTITY_ID, percentage=50) _verify(hass, STATE_ON, 50, None, None, None) +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {"speed_count": 3, **OPTIMISTIC_PERCENTAGE_CONFIG})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_increase_decrease_speed( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set valid increase and decrease speed.""" - await _register_components(hass, speed_count=3) - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 66), @@ -699,100 +1214,101 @@ async def test_increase_decrease_speed( (common.async_decrease_speed, None, STATE_ON, 0), (common.async_increase_speed, None, STATE_ON, 33), ): - await func(hass, _TEST_FAN, extra) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + await func(hass, TEST_ENTITY_ID, extra) _verify(hass, state, value, None, None, None) +@pytest.mark.parametrize( + ("count", "fan_config"), + [ + ( + 1, + { + **OPTIMISTIC_ON_OFF_ACTIONS, + "preset_modes": ["auto"], + **PRESET_MODE_ACTION, + **PERCENTAGE_ACTION, + **OSCILLATE_ACTION, + **DIRECTION_ACTION, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +@pytest.mark.usefixtures("setup_named_fan") 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 = { - **OPTIMISTIC_ON_OFF_CONFIG, - "preset_modes": ["auto"], - **PRESET_MODE_ACTION, - **PERCENTAGE_ACTION, - **OSCILLATE_ACTION, - **DIRECTION_ACTION, - } - assert await setup.async_setup_component( - hass, - "fan", - {"fan": {"platform": "template", "fans": {"test_fan": test_fan_config}}}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) _verify(hass, STATE_ON) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == _TEST_FAN + assert calls[-1].data["caller"] == TEST_ENTITY_ID - await common.async_turn_off(hass, _TEST_FAN) + await common.async_turn_off(hass, TEST_ENTITY_ID) _verify(hass, STATE_OFF) assert len(calls) == 2 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == _TEST_FAN + assert calls[-1].data["caller"] == TEST_ENTITY_ID percent = 100 - await common.async_set_percentage(hass, _TEST_FAN, percent) + await common.async_set_percentage(hass, TEST_ENTITY_ID, percent) _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 + assert calls[-1].data["caller"] == TEST_ENTITY_ID - await common.async_turn_off(hass, _TEST_FAN) + await common.async_turn_off(hass, TEST_ENTITY_ID) _verify(hass, STATE_OFF, percent) assert len(calls) == 4 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == _TEST_FAN + assert calls[-1].data["caller"] == TEST_ENTITY_ID preset = "auto" - await common.async_set_preset_mode(hass, _TEST_FAN, preset) - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == preset + await common.async_set_preset_mode(hass, TEST_ENTITY_ID, 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 + assert calls[-1].data["caller"] == TEST_ENTITY_ID - await common.async_turn_off(hass, _TEST_FAN) + await common.async_turn_off(hass, TEST_ENTITY_ID) _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 + assert calls[-1].data["caller"] == TEST_ENTITY_ID - await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD) + await common.async_set_direction(hass, TEST_ENTITY_ID, 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 + assert calls[-1].data["caller"] == TEST_ENTITY_ID - await common.async_oscillate(hass, _TEST_FAN, True) + await common.async_oscillate(hass, TEST_ENTITY_ID, True) _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 + assert calls[-1].data["caller"] == TEST_ENTITY_ID @pytest.mark.parametrize("count", [1]) -@pytest.mark.parametrize("style", [ConfigurationStyle.LEGACY]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) @pytest.mark.parametrize( ("extra_config", "attribute", "action", "verify_attr", "coro", "value"), [ @@ -830,6 +1346,7 @@ async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) - ), ], ) +@pytest.mark.usefixtures("setup_optimistic_fan_attribute") async def test_optimistic_attributes( hass: HomeAssistant, attribute: str, @@ -837,27 +1354,43 @@ async def test_optimistic_attributes( 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) + await coro(hass, TEST_ENTITY_ID, 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 + assert calls[-1].data["caller"] == TEST_ENTITY_ID +@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_PERCENTAGE_CONFIG)]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_increase_decrease_speed_default_speed_count( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set valid increase and decrease speed.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 99), @@ -865,432 +1398,146 @@ async def test_increase_decrease_speed_default_speed_count( (common.async_decrease_speed, 31, STATE_ON, 67), (common.async_decrease_speed, None, STATE_ON, 66), ): - await func(hass, _TEST_FAN, extra) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + await func(hass, TEST_ENTITY_ID, extra) _verify(hass, state, value, None, None, None) +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_invalid_osc_from_initial_state( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid oscillating when fan is in initial state.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, _TEST_FAN, "invalid") - assert hass.states.get(_OSC_INPUT).state == "" - _verify(hass, STATE_ON, 0, None, None, None) + await common.async_oscillate(hass, TEST_ENTITY_ID, "invalid") + _verify(hass, STATE_ON, None, None, None, None) -async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test set invalid oscillating when fan has valid osc.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) - await common.async_oscillate(hass, _TEST_FAN, True) - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, 0, True, None, None) - - with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, _TEST_FAN, None) - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, 0, True, None, None) - - -def _verify( - hass: HomeAssistant, - expected_state: str, - 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) - attributes = state.attributes - assert state.state == str(expected_state) - assert attributes.get(ATTR_PERCENTAGE) == expected_percentage - assert attributes.get(ATTR_OSCILLATING) == expected_oscillating - assert attributes.get(ATTR_DIRECTION) == expected_direction - assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode - - -async def _register_fan_sources(hass: HomeAssistant) -> None: - with assert_setup_component(1, "input_boolean"): - assert await setup.async_setup_component( - hass, "input_boolean", {"input_boolean": {"state": None}} - ) - - with assert_setup_component(1, "input_number"): - assert await setup.async_setup_component( - hass, - "input_number", - { - "input_number": { - "percentage": { - "min": 0.0, - "max": 100.0, - "name": "Percentage", - "step": 1.0, - "mode": "slider", - } - } - }, - ) - - with assert_setup_component(3, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", - { - "input_select": { - "preset_mode": { - "name": "Preset Mode", - "options": ["auto", "smart"], - }, - "osc": {"name": "oscillating", "options": ["", "True", "False"]}, - "direction": { - "name": "Direction", - "options": ["", DIRECTION_FORWARD, DIRECTION_REVERSE], - }, - } - }, - ) - - -async def _register_components( - hass: HomeAssistant, - speed_list: list[str] | None = None, - preset_modes: list[str] | None = None, - speed_count: int | None = None, -) -> None: - """Register basic components for testing.""" - await _register_fan_sources(hass) - - with assert_setup_component(1, "fan"): - value_template = """ - {% if is_state('input_boolean.state', 'on') %} - {{ 'on' }} - {% else %} - {{ 'off' }} - {% endif %} - """ - - test_fan_config = { - "value_template": value_template, - "preset_mode_template": "{{ states('input_select.preset_mode') }}", - "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": "input_number.set_value", - "data_template": { - "entity_id": _PERCENTAGE_INPUT_NUMBER, - "value": 0, - }, - }, - { - "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 }}", - }, - }, - ], - "set_oscillating": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _OSC_INPUT, - "option": "{{ oscillating }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_oscillating", - "caller": "{{ this.entity_id }}", - "option": "{{ oscillating }}", - }, - }, - ], - "set_direction": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _DIRECTION_INPUT_SELECT, - "option": "{{ direction }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_direction", - "caller": "{{ this.entity_id }}", - "option": "{{ direction }}", - }, - }, - ], - } - - if preset_modes: - test_fan_config["preset_modes"] = preset_modes - - if speed_count: - test_fan_config["speed_count"] = speed_count - - assert await setup.async_setup_component( - hass, - "fan", - {"fan": {"platform": "template", "fans": {"test_fan": test_fan_config}}}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_template_fan_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - "turn_on": { - "service": "fan.turn_on", - "entity_id": "fan.test_state", - }, - "turn_off": { - "service": "fan.turn_off", - "entity_id": "fan.test_state", - }, - }, - "test_template_fan_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - "turn_on": { - "service": "fan.turn_on", - "entity_id": "fan.test_state", - }, - "turn_off": { - "service": "fan.turn_off", - "entity_id": "fan.test_state", - }, - }, - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test set invalid oscillating when fan has valid osc.""" + await common.async_turn_on(hass, TEST_ENTITY_ID) + await common.async_oscillate(hass, TEST_ENTITY_ID, True) + _verify(hass, STATE_ON, None, True, None, None) + + await common.async_oscillate(hass, TEST_ENTITY_ID, False) + _verify(hass, STATE_ON, None, False, None, None) + + with pytest.raises(vol.Invalid): + await common.async_oscillate(hass, TEST_ENTITY_ID, None) + _verify(hass, STATE_ON, None, False, None, None) + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("fan_config", "style"), + [ + ( + { + "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("setup_fan") async def test_unique_id(hass: HomeAssistant) -> None: """Test unique_id option only creates one fan per id.""" assert len(hass.states.async_all()) == 1 @pytest.mark.parametrize( - ("speed_count", "percentage_step"), [(0, 1), (100, 1), (3, 100 / 3)] + ("count", "extra_config"), + [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OPTIMISTIC_PERCENTAGE_CONFIG})], ) -async def test_implemented_percentage( - hass: HomeAssistant, speed_count, percentage_step -) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +@pytest.mark.parametrize( + ("fan_config", "percentage_step"), + [({"speed_count": 0}, 1), ({"speed_count": 100}, 1), ({"speed_count": 3}, 100 / 3)], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +async def test_speed_percentage_step(hass: HomeAssistant, percentage_step) -> None: """Test a fan that implements percentage.""" - await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "mechanical_ventilation": { - "friendly_name": "Mechanische ventilatie", - "unique_id": "a2fd2e38-674b-4b47-b5ef-cc2362211a72", - "value_template": "{{ states('light.mv_snelheid') }}", - "percentage_template": ( - "{{ (state_attr('light.mv_snelheid','brightness') | int /" - " 255 * 100) | int }}" - ), - "turn_on": [ - { - "service": "switch.turn_off", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": 40}, - }, - ], - "turn_off": [ - { - "service": "light.turn_off", - "target": { - "entity_id": "light.mv_snelheid", - }, - }, - { - "service": "switch.turn_on", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - ], - "set_percentage": [ - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": "{{ percentage }}"}, - } - ], - "speed_count": speed_count, - }, - }, - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 - state = hass.states.get("fan.mechanical_ventilation") + state = hass.states.get(TEST_ENTITY_ID) attributes = state.attributes assert attributes["percentage_step"] == percentage_step assert attributes.get("supported_features") & FanEntityFeature.SET_SPEED -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "mechanical_ventilation": { - "friendly_name": "Mechanische ventilatie", - "unique_id": "a2fd2e38-674b-4b47-b5ef-cc2362211a72", - "value_template": "{{ states('light.mv_snelheid') }}", - "preset_mode_template": "{{ 'any' }}", - "preset_modes": ["any"], - "set_preset_mode": [ - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": "{{ percentage }}"}, - } - ], - "turn_on": [ - { - "service": "switch.turn_off", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": 40}, - }, - ], - "turn_off": [ - { - "service": "light.turn_off", - "target": { - "entity_id": "light.mv_snelheid", - }, - }, - { - "service": "switch.turn_on", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - ], - }, - }, - } - }, - ], + ("count", "fan_config"), + [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OPTIMISTIC_PRESET_MODE_CONFIG2})], ) -@pytest.mark.usefixtures("start_ha") -async def test_implemented_preset_mode(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +@pytest.mark.usefixtures("setup_named_fan") +async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: """Test a fan that implements preset_mode.""" assert len(hass.states.async_all()) == 1 - state = hass.states.get("fan.mechanical_ventilation") + state = hass.states.get(TEST_ENTITY_ID) attributes = state.attributes - assert attributes.get("percentage") is None assert attributes.get("supported_features") & FanEntityFeature.PRESET_MODE @@ -1305,6 +1552,13 @@ async def test_implemented_preset_mode(hass: HomeAssistant) -> None: "turn_off": [], }, ), + ( + ConfigurationStyle.MODERN, + { + "turn_on": [], + "turn_off": [], + }, + ), ], ) @pytest.mark.parametrize( @@ -1342,7 +1596,51 @@ async def test_empty_action_config( setup_test_fan_with_extra_config, ) -> None: """Test configuration with empty script.""" - state = hass.states.get(_TEST_FAN) + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["supported_features"] == ( FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON | supported_features ) + + +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", + "fan": [ + { + **OPTIMISTIC_ON_OFF_ACTIONS, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **OPTIMISTIC_ON_OFF_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("fan")) == 2 + + entry = entity_registry.async_get("fan.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("fan.test_b") + assert entry + assert entry.unique_id == "x-b" diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index f240c2412e0..eaa1708aea7 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -79,6 +79,7 @@ OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG = { "action": "set_temperature", "caller": "{{ this.entity_id }}", "color_temp": "{{color_temp}}", + "color_temp_kelvin": "{{color_temp_kelvin}}", }, }, } @@ -1535,6 +1536,7 @@ async def test_temperature_action_no_template( assert calls[-1].data["action"] == "set_temperature" assert calls[-1].data["caller"] == "light.test_template_light" assert calls[-1].data["color_temp"] == 345 + assert calls[-1].data["color_temp_kelvin"] == 2898 state = hass.states.get("light.test_template_light") assert state is not None diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 50baa11b2d0..4435e4a2404 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -1,9 +1,11 @@ """The tests for the Template lock platform.""" +from typing import Any + import pytest from homeassistant import setup -from homeassistant.components import lock +from homeassistant.components import lock, template from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ( ATTR_CODE, @@ -14,25 +16,38 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) 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 -OPTIMISTIC_LOCK_CONFIG = { - "platform": "template", +TEST_OBJECT_ID = "test_template_lock" +TEST_ENTITY_ID = f"lock.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "switch.test_state" + +LOCK_ACTION = { "lock": { "service": "test.automation", "data_template": { "action": "lock", "caller": "{{ this.entity_id }}", + "code": "{{ code if code is defined else None }}", }, }, +} +UNLOCK_ACTION = { "unlock": { "service": "test.automation", "data_template": { "action": "unlock", "caller": "{{ this.entity_id }}", + "code": "{{ code if code is defined else None }}", }, }, +} +OPEN_ACTION = { "open": { "service": "test.automation", "data_template": { @@ -42,424 +57,565 @@ OPTIMISTIC_LOCK_CONFIG = { }, } -OPTIMISTIC_CODED_LOCK_CONFIG = { - "platform": "template", - "lock": { - "service": "test.automation", - "data_template": { - "action": "lock", - "caller": "{{ this.entity_id }}", - "code": "{{ code }}", - }, - }, - "unlock": { - "service": "test.automation", - "data_template": { - "action": "unlock", - "caller": "{{ this.entity_id }}", - "code": "{{ code }}", - }, - }, + +OPTIMISTIC_LOCK = { + **LOCK_ACTION, + **UNLOCK_ACTION, } -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +OPTIMISTIC_LOCK_CONFIG = { + "platform": "template", + **LOCK_ACTION, + **UNLOCK_ACTION, + **OPEN_ACTION, +} + +OPTIMISTIC_CODED_LOCK_CONFIG = { + "platform": "template", + **LOCK_ACTION, + **UNLOCK_ACTION, +} + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, lock_config: dict[str, Any] +) -> None: + """Do setup of lock integration via legacy format.""" + config = {"lock": {"platform": "template", "name": TEST_OBJECT_ID, **lock_config}} + + with assert_setup_component(count, lock.DOMAIN): + assert await async_setup_component( + hass, + lock.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, lock_config: dict[str, Any] +) -> None: + """Do setup of lock integration via modern format.""" + config = {"template": {"lock": {"name": TEST_OBJECT_ID, **lock_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() + + +@pytest.fixture +async def setup_lock( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + lock_config: dict[str, Any], +) -> None: + """Do setup of lock integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, lock_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, lock_config) + + +@pytest.fixture +async def setup_base_lock( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: dict, +): + """Do setup of cover integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + {"value_template": state_template, **extra_config}, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {"state": state_template, **extra_config}, + ) + + +@pytest.fixture +async def setup_state_lock( + 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, + { + **OPTIMISTIC_LOCK, + "value_template": state_template, + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **OPTIMISTIC_LOCK, + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_state_lock_with_extra_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: dict, +): + """Do setup of cover integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + {**OPTIMISTIC_LOCK, "value_template": state_template, **extra_config}, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {**OPTIMISTIC_LOCK, "state": state_template, **extra_config}, + ) + + +@pytest.fixture +async def setup_state_lock_with_attribute( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + attribute: str, + attribute_template: str, +): + """Do setup of cover integration using a state template.""" + extra = {attribute: attribute_template} if attribute else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + **OPTIMISTIC_LOCK, + "value_template": state_template, + **extra, + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {**OPTIMISTIC_LOCK, "state": state_template, **extra}, + ) + + @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "name": "Test template lock", - "value_template": "{{ states.switch.test_state.state }}", - } - }, - ], + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock") async def test_template_state(hass: HomeAssistant) -> None: """Test template.""" - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() state = hass.states.get("lock.test_template_lock") assert state.state == LockState.LOCKED - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() state = hass.states.get("lock.test_template_lock") assert state.state == LockState.UNLOCKED - hass.states.async_set("switch.test_state", STATE_OPEN) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OPEN) await hass.async_block_till_done() state = hass.states.get("lock.test_template_lock") assert state.state == LockState.OPEN -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "name": "Test lock", - "optimistic": True, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - ], + ("count", "state_template", "extra_config"), + [(1, "{{ states.switch.test_state.state }}", {"optimistic": True, **OPEN_ACTION})], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock_with_extra_config") async def test_open_lock_optimistic( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test optimistic open.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.test_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_OPEN, - {ATTR_ENTITY_ID: "lock.test_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "open" - assert calls[0].data["caller"] == "lock.test_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID - state = hass.states.get("lock.test_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.OPEN -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - } - }, - ], + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock") async def test_template_state_boolean_on(hass: HomeAssistant) -> None: """Test the setting of the state with boolean on.""" - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 2 }}")]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 2 }}", - } - }, - ], + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock") async def test_template_state_boolean_off(hass: HomeAssistant) -> None: """Test the setting of the state with off.""" - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED -@pytest.mark.parametrize(("count", "domain"), [(0, lock.DOMAIN)]) +@pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("state_template", "extra_config"), [ - { - lock.DOMAIN: { - "platform": "template", - "value_template": "{% if rubbish %}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - { - "switch": { - "platform": "lock", - "name": "{{%}", - "value_template": "{{ rubbish }", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - }, - }, - {lock.DOMAIN: {"platform": "template", "value_template": "Invalid"}}, - { - lock.DOMAIN: { - "platform": "template", + ("{% if rubbish %}", OPTIMISTIC_LOCK), + ("{{ rubbish }", OPTIMISTIC_LOCK), + ("Invalid", {}), + ( + "{{ 1==1 }}", + { "not_value_template": "{{ states.switch.test_state.state }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - "code_format_template": "{{ rubbish }", - } - }, - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - "code_format_template": "{% if rubbish %}", - } - }, + **OPTIMISTIC_LOCK, + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_base_lock") async def test_template_syntax_error(hass: HomeAssistant) -> None: """Test templating syntax errors don't create entities.""" assert hass.states.async_all("lock") == [] -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize(("count", "state_template"), [(0, "{{ 1==1 }}")]) +@pytest.mark.parametrize("attribute_template", ["{{ rubbish }", "{% if rubbish %}"]) @pytest.mark.parametrize( - "config", + ("style", "attribute"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 + 1 }}", - } - }, + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock_with_attribute") +async def test_template_code_template_syntax_error(hass: HomeAssistant) -> None: + """Test templating code_format syntax errors don't create entities.""" + assert hass.states.async_all("lock") == [] + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 + 1 }}")]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock") async def test_template_static(hass: HomeAssistant) -> None: """Test that we allow static templates.""" - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED - hass.states.async_set("lock.template_lock", LockState.LOCKED) + hass.states.async_set(TEST_ENTITY_ID, LockState.LOCKED) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("state_template", "expected"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, + ("{{ True }}", LockState.LOCKED), + ("{{ False }}", LockState.UNLOCKED), + ("{{ x - 12 }}", STATE_UNAVAILABLE), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test lock action.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_OFF) +@pytest.mark.usefixtures("setup_state_lock") +async def test_state_template(hass: HomeAssistant, expected: str) -> None: + """Test state and value_template template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")]) +@pytest.mark.parametrize( + "attribute_template", + ["{% if states.switch.test_state.state %}/local/switch.png{% endif %}"], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "picture"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") +async def test_picture_template(hass: HomeAssistant) -> None: + """Test entity_picture template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") in ("", None) + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["entity_picture"] == "/local/switch.png" + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")]) +@pytest.mark.parametrize( + "attribute_template", + ["{% if states.switch.test_state.state %}mdi:eye{% endif %}"], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "icon"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") +async def test_icon_template(hass: HomeAssistant) -> None: + """Test entity_picture template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") in ("", None) + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:eye" + + +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock") +async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test lock action.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "lock" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - ], + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock") async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test unlock action.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "unlock" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - ], + ("count", "state_template", "extra_config"), + [(1, "{{ states.switch.test_state.state }}", OPEN_ACTION)], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock_with_extra_config") async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test open action.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_OPEN, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "open" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_CODED_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "code_format_template": "{{ '.+' }}", - } - }, + ( + 1, + "{{ states.switch.test_state.state }}", + "{{ '.+' }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_lock_action_with_code( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test lock action with defined code format and supplied lock code.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "LOCK_CODE"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "LOCK_CODE"}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "lock" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID assert calls[0].data["code"] == "LOCK_CODE" -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_CODED_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "code_format_template": "{{ '.+' }}", - } - }, + ( + 1, + "{{ states.switch.test_state.state }}", + "{{ '.+' }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_unlock_action_with_code( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test unlock action with code format and supplied unlock code.""" await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "UNLOCK_CODE"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "UNLOCK_CODE"}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "unlock" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID assert calls[0].data["code"] == "UNLOCK_CODE" -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - "code_format_template": "{{ '\\\\d+' }}", - } - }, + ( + 1, + "{{ 1 == 1 }}", + "{{ '\\\\d+' }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), ], ) @pytest.mark.parametrize( @@ -469,7 +625,7 @@ async def test_unlock_action_with_code( lock.SERVICE_UNLOCK, ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_lock_actions_fail_with_invalid_code( hass: HomeAssistant, calls: list[ServiceCall], test_action ) -> None: @@ -477,32 +633,36 @@ async def test_lock_actions_fail_with_invalid_code( await hass.services.async_call( lock.DOMAIN, test_action, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "non-number-value"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "non-number-value"}, ) await hass.services.async_call( lock.DOMAIN, test_action, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 0 -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - "code_format_template": "{{ 1/0 }}", - } - }, + ( + 1, + "{{ 1 == 1 }}", + "{{ 1/0 }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_lock_actions_dont_execute_with_code_template_rendering_error( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: @@ -510,142 +670,146 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error( await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "any-value"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "any-value"}, ) await hass.async_block_till_done() assert len(calls) == 0 -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) -@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_CODED_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "code_format_template": "{{ None }}", - } - }, + ( + 1, + "{{ states.switch.test_state.state }}", + "{{ None }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_actions_with_none_as_codeformat_ignores_code( hass: HomeAssistant, action, calls: list[ServiceCall] ) -> None: """Test lock actions with supplied lock code.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "any code"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "any code"}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == action - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID assert calls[0].data["code"] == "any code" -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) -@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "code_format_template": "[12]{1", - } - }, + ( + 1, + "{{ states.switch.test_state.state }}", + "[12]{1", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_actions_with_invalid_regexp_as_codeformat_never_execute( hass: HomeAssistant, action, calls: list[ServiceCall] ) -> None: """Test lock actions don't execute with invalid regexp.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "1"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "1"}, ) await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "x"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "x"}, ) await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 0 -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.input_select.test_state.state }}", - } - }, - ], + ("count", "state_template"), [(1, "{{ states.input_select.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.parametrize( "test_state", [LockState.UNLOCKING, LockState.LOCKING, LockState.JAMMED] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock") async def test_lock_state(hass: HomeAssistant, test_state) -> None: """Test value template.""" hass.states.async_set("input_select.test_state", test_state) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == test_state -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states('switch.test_state') }}", - "availability_template": "{{ is_state('availability_state.state', 'on') }}", - } - }, + ( + 1, + "{{ states('switch.test_state') }}", + "{{ is_state('availability_state.state', 'on') }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" # When template returns true.. @@ -653,35 +817,39 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Device State should not be unavailable - assert hass.states.get("lock.template_lock").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE # When Availability template returns false hass.states.async_set("availability_state.state", STATE_OFF) await hass.async_block_till_done() # device state should be unavailable - assert hass.states.get("lock.template_lock").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 + 1 }}", - "availability_template": "{{ x - 12 }}", - } - }, + ( + 1, + "{{ 1 + 1 }}", + "{{ x - 12 }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") 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("lock.template_lock").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE assert ("UndefinedError: 'x' is undefined") in caplog_setup_text @@ -700,7 +868,7 @@ async def test_invalid_availability_template_keeps_component_available( ], ) @pytest.mark.usefixtures("start_ha") -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_legacy_unique_id(hass: HomeAssistant) -> None: """Test unique_id option only creates one lock per id.""" await setup.async_setup_component( hass, @@ -722,6 +890,85 @@ async def test_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all("lock")) == 1 +async def test_modern_unique_id(hass: HomeAssistant) -> None: + """Test unique_id option only creates one cover per id.""" + config = { + "template": { + "lock": [ + { + "name": "test_template_lock_01", + "unique_id": "not-so-unique-anymore", + "state": "{{ false }}", + **OPTIMISTIC_LOCK, + }, + { + "name": "test_template_lock_02", + "unique_id": "not-so-unique-anymore", + "state": "{{ false }}", + **OPTIMISTIC_LOCK, + }, + ] + } + } + + with assert_setup_component(1, 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() + + assert len(hass.states.async_all()) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to lock unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "lock": [ + { + **OPTIMISTIC_LOCK, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **OPTIMISTIC_LOCK, + "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("lock")) == 2 + + entry = entity_registry.async_get("lock.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("lock.test_b") + assert entry + assert entry.unique_id == "x-b" + + async def test_emtpy_action_config(hass: HomeAssistant) -> None: """Test configuration with empty script.""" with assert_setup_component(1, lock.DOMAIN): diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index 49b89b61d34..6de07612c36 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -788,6 +788,39 @@ async def test_if_fires_on_change_with_for_template_3( assert len(calls) == 1 +@pytest.mark.parametrize(("count", "domain"), [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + automation.DOMAIN: { + "trigger_variables": { + "seconds": 5, + "entity": "test.entity", + }, + "trigger": { + "platform": "template", + "value_template": "{{ is_state(entity, 'world') }}", + "for": "{{ seconds }}", + }, + "action": {"service": "test.automation"}, + } + }, + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_if_fires_on_change_with_for_template_4( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test for firing on change with for template.""" + hass.states.async_set("test.entity", "world") + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(calls) == 1 + + @pytest.mark.parametrize(("count", "domain"), [(1, automation.DOMAIN)]) @pytest.mark.parametrize( "config", diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index cc5bc9b39e3..90ca0b56afb 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -4,16 +4,17 @@ from typing import Any import pytest -from homeassistant import setup -from homeassistant.components import vacuum +from homeassistant.components import template, vacuum from homeassistant.components.vacuum import ( ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, VacuumActivity, VacuumEntityFeature, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component @@ -22,19 +23,91 @@ from .conftest import ConfigurationStyle from tests.common import assert_setup_component from tests.components.vacuum import common -_TEST_OBJECT_ID = "test_vacuum" -_TEST_VACUUM = f"vacuum.{_TEST_OBJECT_ID}" -_STATE_INPUT_SELECT = "input_select.state" -_SPOT_CLEANING_INPUT_BOOLEAN = "input_boolean.spot_cleaning" -_LOCATING_INPUT_BOOLEAN = "input_boolean.locating" -_FAN_SPEED_INPUT_SELECT = "input_select.fan_speed" -_BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" +TEST_OBJECT_ID = "test_vacuum" +TEST_ENTITY_ID = f"vacuum.{TEST_OBJECT_ID}" + +STATE_INPUT_SELECT = "input_select.state" +BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" + +START_ACTION = { + "start": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "start", + }, + }, +} + + +TEMPLATE_VACUUM_ACTIONS = { + **START_ACTION, + "pause": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "pause", + }, + }, + "stop": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "stop", + }, + }, + "return_to_base": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "return_to_base", + }, + }, + "clean_spot": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "clean_spot", + }, + }, + "locate": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "locate", + }, + }, + "set_fan_speed": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "set_fan_speed", + "fan_speed": "{{ fan_speed }}", + }, + }, +} + +UNIQUE_ID_CONFIG = {"unique_id": "not-so-unique-anymore", **TEMPLATE_VACUUM_ACTIONS} + + +def _verify( + hass: HomeAssistant, + expected_state: str, + expected_battery_level: int | None = None, + expected_fan_speed: int | None = None, +) -> None: + """Verify vacuum's state and speed.""" + state = hass.states.get(TEST_ENTITY_ID) + attributes = state.attributes + assert state.state == expected_state + assert attributes.get(ATTR_BATTERY_LEVEL) == expected_battery_level + assert attributes.get(ATTR_FAN_SPEED) == expected_fan_speed async def async_setup_legacy_format( hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] ) -> None: - """Do setup of number integration via new format.""" + """Do setup of vacuum integration via new format.""" config = {"vacuum": {"platform": "template", "vacuums": vacuum_config}} with assert_setup_component(count, vacuum.DOMAIN): @@ -49,6 +122,24 @@ async def async_setup_legacy_format( await hass.async_block_till_done() +async def async_setup_modern_format( + hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] +) -> None: + """Do setup of vacuum integration via modern format.""" + config = {"template": {"vacuum": vacuum_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_vacuum( hass: HomeAssistant, @@ -59,6 +150,8 @@ async def setup_vacuum( """Do setup of number integration.""" if style == ConfigurationStyle.LEGACY: await async_setup_legacy_format(hass, count, vacuum_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, vacuum_config) @pytest.fixture @@ -70,160 +163,406 @@ async def setup_test_vacuum_with_extra_config( extra_config: dict[str, Any], ) -> None: """Do setup of number integration.""" - config = {_TEST_OBJECT_ID: {**vacuum_config, **extra_config}} if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, config) + await async_setup_legacy_format( + hass, count, {TEST_OBJECT_ID: {**vacuum_config, **extra_config}} + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": TEST_OBJECT_ID, **vacuum_config, **extra_config} + ) -@pytest.mark.parametrize(("count", "domain"), [(1, "vacuum")]) +@pytest.fixture +async def setup_state_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of vacuum integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + "value_template": state_template, + **TEMPLATE_VACUUM_ACTIONS, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **TEMPLATE_VACUUM_ACTIONS, + }, + ) + + +@pytest.fixture +async def setup_base_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str | None, + extra_config: dict, +): + """Do setup of vacuum integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + state_config = {"value_template": state_template} if state_template else {} + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **state_config, + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + state_config = {"state": state_template} if state_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **state_config, + **extra_config, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_state_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str | None, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of vacuum integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + state_config = {"value_template": state_template} if state_template else {} + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + **extra, + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + state_config = {"state": state_template} if state_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + **extra, + **extra_config, + }, + ) + + +@pytest.fixture +async def setup_attributes_state_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str | None, + attributes: dict, +) -> None: + """Do setup of vacuum integration testing a single attribute.""" + if style == ConfigurationStyle.LEGACY: + state_config = {"value_template": state_template} if state_template else {} + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + "attribute_templates": attributes, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + state_config = {"state": state_template} if state_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "attributes": attributes, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + }, + ) + + +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("parm1", "parm2", "config"), + ("style", "state_template", "extra_config", "parm1", "parm2"), [ ( + ConfigurationStyle.LEGACY, + None, + {"start": {"service": "script.vacuum_start"}}, STATE_UNKNOWN, None, - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": {"start": {"service": "script.vacuum_start"}} - }, - } - }, ), ( + ConfigurationStyle.MODERN, + None, + {"start": {"service": "script.vacuum_start"}}, + STATE_UNKNOWN, + None, + ), + ( + ConfigurationStyle.LEGACY, + "{{ 'cleaning' }}", + { + "battery_level_template": "{{ 100 }}", + "start": {"service": "script.vacuum_start"}, + }, VacuumActivity.CLEANING, 100, - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ 'cleaning' }}", - "battery_level_template": "{{ 100 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, ), ( - STATE_UNKNOWN, - None, + ConfigurationStyle.MODERN, + "{{ 'cleaning' }}", { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ 'abc' }}", - "battery_level_template": "{{ 101 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } + "battery_level": "{{ 100 }}", + "start": {"service": "script.vacuum_start"}, }, + VacuumActivity.CLEANING, + 100, ), ( + ConfigurationStyle.LEGACY, + "{{ 'abc' }}", + { + "battery_level_template": "{{ 101 }}", + "start": {"service": "script.vacuum_start"}, + }, STATE_UNKNOWN, None, + ), + ( + ConfigurationStyle.MODERN, + "{{ 'abc' }}", { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ this_function_does_not_exist() }}", - "battery_level_template": "{{ this_function_does_not_exist() }}", - "fan_speed_template": "{{ this_function_does_not_exist() }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } + "battery_level": "{{ 101 }}", + "start": {"service": "script.vacuum_start"}, }, + STATE_UNKNOWN, + None, + ), + ( + ConfigurationStyle.LEGACY, + "{{ this_function_does_not_exist() }}", + { + "battery_level_template": "{{ this_function_does_not_exist() }}", + "fan_speed_template": "{{ this_function_does_not_exist() }}", + "start": {"service": "script.vacuum_start"}, + }, + STATE_UNKNOWN, + None, + ), + ( + ConfigurationStyle.MODERN, + "{{ this_function_does_not_exist() }}", + { + "battery_level": "{{ this_function_does_not_exist() }}", + "fan_speed": "{{ this_function_does_not_exist() }}", + "start": {"service": "script.vacuum_start"}, + }, + STATE_UNKNOWN, + None, ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_valid_configs(hass: HomeAssistant, count, parm1, parm2) -> None: +@pytest.mark.usefixtures("setup_base_vacuum") +async def test_valid_legacy_configs(hass: HomeAssistant, count, parm1, parm2) -> None: """Test: configs.""" assert len(hass.states.async_all("vacuum")) == count _verify(hass, parm1, parm2) -@pytest.mark.parametrize(("count", "domain"), [(0, "vacuum")]) +@pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("state_template", "extra_config"), [ - { - "vacuum": { - "platform": "template", - "vacuums": {"test_vacuum": {"value_template": "{{ 'on' }}"}}, - } - }, - { - "platform": "template", - "vacuums": {"test_vacuum": {"start": {"service": "script.vacuum_start"}}}, - }, + ("{{ 'on' }}", {}), + (None, {"nothingburger": {"service": "script.vacuum_start"}}), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_base_vacuum") async def test_invalid_configs(hass: HomeAssistant, count) -> None: """Test: configs.""" assert len(hass.states.async_all("vacuum")) == count @pytest.mark.parametrize( - ("count", "domain", "config"), + ("count", "state_template", "extra_config"), + [(1, "{{ states('input_select.state') }}", {})], +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - ( - 1, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ states('input_select.state') }}", - "battery_level_template": "{{ states('input_number.battery_level') }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) + (ConfigurationStyle.LEGACY, "battery_level_template"), + (ConfigurationStyle.MODERN, "battery_level"), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_templates_with_entities(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ '0' }}", 0), + ("{{ 100 }}", 100), + ("{{ 101 }}", None), + ("{{ -1 }}", None), + ("{{ 'foo' }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_battery_level_template( + hass: HomeAssistant, expected: int | None +) -> None: """Test templates with values from other entities.""" - _verify(hass, STATE_UNKNOWN, None) - - hass.states.async_set(_STATE_INPUT_SELECT, VacuumActivity.CLEANING) - hass.states.async_set(_BATTERY_LEVEL_INPUT_NUMBER, 100) - await hass.async_block_till_done() - _verify(hass, VacuumActivity.CLEANING, 100) + _verify(hass, STATE_UNKNOWN, expected) @pytest.mark.parametrize( - ("count", "domain", "config"), + ("count", "state_template", "extra_config"), [ ( 1, - "vacuum", + "{{ states('input_select.state') }}", { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "availability_template": "{{ is_state('availability_state.state', 'on') }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } + "fan_speeds": ["low", "medium", "high"], }, ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "fan_speed_template"), + (ConfigurationStyle.MODERN, "fan_speed"), + ], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 'low' }}", "low"), + ("{{ 'medium' }}", "medium"), + ("{{ 'high' }}", "high"), + ("{{ 'invalid' }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_fan_speed_template(hass: HomeAssistant, expected: str | None) -> None: + """Test templates with values from other entities.""" + _verify(hass, STATE_UNKNOWN, None, expected) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 'on' }}", + "{% if states.switch.test_state.state %}mdi:check{% endif %}", + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "icon"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_icon_template(hass: HomeAssistant) -> None: + """Test icon template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") in ("", None) + + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:check" + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 'on' }}", + "{% if states.switch.test_state.state %}local/vacuum.png{% endif %}", + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "picture"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_picture_template(hass: HomeAssistant) -> None: + """Test picture template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") in ("", None) + + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["entity_picture"] == "local/vacuum.png" + + +@pytest.mark.parametrize("extra_config", [{}]) +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + None, + "{{ is_state('availability_state.state', 'on') }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" @@ -232,105 +571,83 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Device State should not be unavailable - assert hass.states.get("vacuum.test_template_vacuum").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE # When Availability template returns false hass.states.async_set("availability_state.state", STATE_OFF) await hass.async_block_till_done() # device state should be unavailable - assert hass.states.get("vacuum.test_template_vacuum").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE +@pytest.mark.parametrize("extra_config", [{}]) @pytest.mark.parametrize( - ("count", "domain", "config"), + ("count", "state_template", "attribute_template"), [ ( 1, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "availability_template": "{{ x - 12 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, + None, + "{{ x - 12 }}", ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") 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("vacuum.test_template_vacuum") != 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", "config"), + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("count", "state_template", "attributes"), [ ( 1, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "value_template": "{{ 'cleaning' }}", - "start": {"service": "script.vacuum_start"}, - "attribute_templates": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - } - }, - } - }, + "{{ 'cleaning' }}", + {"test_attribute": "It {{ states.sensor.test_state.state }}."}, ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_attributes_state_vacuum") async def test_attribute_templates(hass: HomeAssistant) -> None: """Test attribute_templates template.""" - state = hass.states.get("vacuum.test_template_vacuum") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["test_attribute"] == "It ." hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - await async_update_entity(hass, "vacuum.test_template_vacuum") - state = hass.states.get("vacuum.test_template_vacuum") + await async_update_entity(hass, TEST_ENTITY_ID) + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["test_attribute"] == "It Works." @pytest.mark.parametrize( - ("count", "domain", "config"), + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("count", "state_template", "attributes"), [ ( 1, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "invalid_template": { - "value_template": "{{ states('input_select.state') }}", - "start": {"service": "script.vacuum_start"}, - "attribute_templates": { - "test_attribute": "{{ this_function_does_not_exist() }}" - }, - } - }, - } - }, + "{{ states('input_select.state') }}", + {"test_attribute": "{{ this_function_does_not_exist() }}"}, ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_attributes_state_vacuum") async def test_invalid_attribute_template( hass: HomeAssistant, caplog_setup_text ) -> None: @@ -340,420 +657,6 @@ async def test_invalid_attribute_template( assert "TemplateError" in caplog_setup_text -@pytest.mark.parametrize( - ("count", "domain", "config"), - [ - ( - 1, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - "start": {"service": "script.vacuum_start"}, - }, - "test_template_vacuum_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - "start": {"service": "script.vacuum_start"}, - }, - }, - } - }, - ), - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_unique_id(hass: HomeAssistant) -> None: - """Test unique_id option only creates one vacuum per id.""" - assert len(hass.states.async_all("vacuum")) == 1 - - -async def test_unused_services(hass: HomeAssistant) -> None: - """Test calling unused services raises.""" - await _register_basic_vacuum(hass) - - # Pause vacuum - with pytest.raises(HomeAssistantError): - await common.async_pause(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Stop vacuum - with pytest.raises(HomeAssistantError): - await common.async_stop(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Return vacuum to base - with pytest.raises(HomeAssistantError): - await common.async_return_to_base(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Spot cleaning - with pytest.raises(HomeAssistantError): - await common.async_clean_spot(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Locate vacuum - with pytest.raises(HomeAssistantError): - await common.async_locate(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Set fan's speed - with pytest.raises(HomeAssistantError): - await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN, None) - - -async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test state services.""" - await _register_components(hass) - - # Start vacuum - await common.async_start(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.CLEANING - _verify(hass, VacuumActivity.CLEANING, None) - assert len(calls) == 1 - assert calls[-1].data["action"] == "start" - assert calls[-1].data["caller"] == _TEST_VACUUM - - # Pause vacuum - await common.async_pause(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.PAUSED - _verify(hass, VacuumActivity.PAUSED, None) - assert len(calls) == 2 - assert calls[-1].data["action"] == "pause" - assert calls[-1].data["caller"] == _TEST_VACUUM - - # Stop vacuum - await common.async_stop(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.IDLE - _verify(hass, VacuumActivity.IDLE, None) - assert len(calls) == 3 - assert calls[-1].data["action"] == "stop" - assert calls[-1].data["caller"] == _TEST_VACUUM - - # Return vacuum to base - await common.async_return_to_base(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.RETURNING - _verify(hass, VacuumActivity.RETURNING, None) - assert len(calls) == 4 - assert calls[-1].data["action"] == "return_to_base" - assert calls[-1].data["caller"] == _TEST_VACUUM - - -async def test_clean_spot_service( - hass: HomeAssistant, calls: list[ServiceCall] -) -> None: - """Test clean spot service.""" - await _register_components(hass) - - # Clean spot - await common.async_clean_spot(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_SPOT_CLEANING_INPUT_BOOLEAN).state == STATE_ON - assert len(calls) == 1 - assert calls[-1].data["action"] == "clean_spot" - assert calls[-1].data["caller"] == _TEST_VACUUM - - -async def test_locate_service(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test locate service.""" - await _register_components(hass) - - # Locate vacuum - await common.async_locate(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_LOCATING_INPUT_BOOLEAN).state == STATE_ON - assert len(calls) == 1 - assert calls[-1].data["action"] == "locate" - assert calls[-1].data["caller"] == _TEST_VACUUM - - -async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test set valid fan speed.""" - await _register_components(hass) - - # Set vacuum's fan speed to high - await common.async_set_fan_speed(hass, "high", _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "high" - assert len(calls) == 1 - assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == _TEST_VACUUM - assert calls[-1].data["option"] == "high" - - # Set fan's speed to medium - await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "medium" - assert len(calls) == 2 - assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == _TEST_VACUUM - assert calls[-1].data["option"] == "medium" - - -async def test_set_invalid_fan_speed( - hass: HomeAssistant, calls: list[ServiceCall] -) -> None: - """Test set invalid fan speed when fan has valid speed.""" - await _register_components(hass) - - # Set vacuum's fan speed to high - await common.async_set_fan_speed(hass, "high", _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "high" - - # Set vacuum's fan speed to 'invalid' - await common.async_set_fan_speed(hass, "invalid", _TEST_VACUUM) - await hass.async_block_till_done() - - # verify fan speed is unchanged - assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "high" - - -def _verify( - hass: HomeAssistant, expected_state: str, expected_battery_level: int -) -> None: - """Verify vacuum's state and speed.""" - state = hass.states.get(_TEST_VACUUM) - attributes = state.attributes - assert state.state == expected_state - assert attributes.get(ATTR_BATTERY_LEVEL) == expected_battery_level - - -async def _register_basic_vacuum(hass: HomeAssistant) -> None: - """Register basic vacuum with only required options for testing.""" - with assert_setup_component(1, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", - { - "input_select": { - "state": {"name": "State", "options": [VacuumActivity.CLEANING]} - } - }, - ) - - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "start": { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.CLEANING, - }, - } - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def _register_components(hass: HomeAssistant) -> None: - """Register basic components for testing.""" - with assert_setup_component(2, "input_boolean"): - assert await setup.async_setup_component( - hass, - "input_boolean", - {"input_boolean": {"spot_cleaning": None, "locating": None}}, - ) - - with assert_setup_component(2, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", - { - "input_select": { - "state": { - "name": "State", - "options": [ - VacuumActivity.CLEANING, - VacuumActivity.DOCKED, - VacuumActivity.IDLE, - VacuumActivity.PAUSED, - VacuumActivity.RETURNING, - ], - }, - "fan_speed": { - "name": "Fan speed", - "options": ["", "low", "medium", "high"], - }, - } - }, - ) - - with assert_setup_component(1, "vacuum"): - test_vacuum_config = { - "value_template": "{{ states('input_select.state') }}", - "fan_speed_template": "{{ states('input_select.fan_speed') }}", - "start": [ - { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.CLEANING, - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "start", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "pause": [ - { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.PAUSED, - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "pause", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "stop": [ - { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.IDLE, - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "stop", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "return_to_base": [ - { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.RETURNING, - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "return_to_base", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "clean_spot": [ - { - "service": "input_boolean.turn_on", - "entity_id": _SPOT_CLEANING_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "clean_spot", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "locate": [ - { - "service": "input_boolean.turn_on", - "entity_id": _LOCATING_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "locate", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "set_fan_speed": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _FAN_SPEED_INPUT_SELECT, - "option": "{{ fan_speed }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_fan_speed", - "caller": "{{ this.entity_id }}", - "option": "{{ fan_speed }}", - }, - }, - ], - "fan_speeds": ["low", "medium", "high"], - "attribute_templates": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - } - - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": {"test_vacuum": test_vacuum_config}, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("style", "vacuum_config"), @@ -761,11 +664,262 @@ async def _register_components(hass: HomeAssistant) -> None: ( ConfigurationStyle.LEGACY, { - "start": [], + "test_template_vacuum_01": { + "value_template": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + "test_template_vacuum_02": { + "value_template": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, }, ), + ( + ConfigurationStyle.MODERN, + [ + { + "name": "test_template_vacuum_01", + "state": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_vacuum_02", + "state": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, + ], + ), ], ) +@pytest.mark.usefixtures("setup_vacuum") +async def test_unique_id(hass: HomeAssistant) -> None: + """Test unique_id option only creates one vacuum per id.""" + assert len(hass.states.async_all("vacuum")) == 1 + + +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), [(1, None, START_ACTION)] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_base_vacuum") +async def test_unused_services(hass: HomeAssistant) -> None: + """Test calling unused services raises.""" + # Pause vacuum + with pytest.raises(HomeAssistantError): + await common.async_pause(hass, TEST_ENTITY_ID) + await hass.async_block_till_done() + + # Stop vacuum + with pytest.raises(HomeAssistantError): + await common.async_stop(hass, TEST_ENTITY_ID) + await hass.async_block_till_done() + + # Return vacuum to base + with pytest.raises(HomeAssistantError): + await common.async_return_to_base(hass, TEST_ENTITY_ID) + await hass.async_block_till_done() + + # Spot cleaning + with pytest.raises(HomeAssistantError): + await common.async_clean_spot(hass, TEST_ENTITY_ID) + await hass.async_block_till_done() + + # Locate vacuum + with pytest.raises(HomeAssistantError): + await common.async_locate(hass, TEST_ENTITY_ID) + await hass.async_block_till_done() + + # Set fan's speed + with pytest.raises(HomeAssistantError): + await common.async_set_fan_speed(hass, "medium", TEST_ENTITY_ID) + await hass.async_block_till_done() + + _verify(hass, STATE_UNKNOWN, None) + + +@pytest.mark.parametrize( + ("count", "state_template"), + [(1, "{{ states('input_select.state') }}")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + "action", + [ + "start", + "pause", + "stop", + "clean_spot", + "return_to_base", + "locate", + ], +) +@pytest.mark.usefixtures("setup_state_vacuum") +async def test_state_services( + hass: HomeAssistant, action: str, calls: list[ServiceCall] +) -> None: + """Test locate service.""" + + await hass.services.async_call( + "vacuum", + action, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # verify + assert len(calls) == 1 + assert calls[-1].data["action"] == action + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ states('input_select.state') }}", + "{{ states('input_select.fan_speed') }}", + { + "fan_speeds": ["low", "medium", "high"], + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "fan_speed_template"), + (ConfigurationStyle.MODERN, "fan_speed"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test set valid fan speed.""" + + # Set vacuum's fan speed to high + await common.async_set_fan_speed(hass, "high", TEST_ENTITY_ID) + await hass.async_block_till_done() + + # verify + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_fan_speed" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["fan_speed"] == "high" + + # Set fan's speed to medium + await common.async_set_fan_speed(hass, "medium", TEST_ENTITY_ID) + await hass.async_block_till_done() + + # verify + assert len(calls) == 2 + assert calls[-1].data["action"] == "set_fan_speed" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["fan_speed"] == "medium" + + +@pytest.mark.parametrize( + "extra_config", + [ + { + "fan_speeds": ["low", "medium", "high"], + } + ], +) +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ states('input_select.state') }}", + "{{ states('input_select.fan_speed') }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "fan_speed_template"), + (ConfigurationStyle.MODERN, "fan_speed"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_set_invalid_fan_speed( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test set invalid fan speed when fan has valid speed.""" + + # Set vacuum's fan speed to high + await common.async_set_fan_speed(hass, "high", TEST_ENTITY_ID) + await hass.async_block_till_done() + + # verify + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_fan_speed" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["fan_speed"] == "high" + + # Set vacuum's fan speed to 'invalid' + await common.async_set_fan_speed(hass, "invalid", TEST_ENTITY_ID) + await hass.async_block_till_done() + + # verify fan speed is unchanged + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_fan_speed" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["fan_speed"] == "high" + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to switch unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "vacuum": [ + { + **TEMPLATE_VACUUM_ACTIONS, + "name": "test_a", + "unique_id": "a", + }, + { + **TEMPLATE_VACUUM_ACTIONS, + "name": "test_b", + "unique_id": "b", + }, + ], + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("vacuum")) == 2 + + entry = entity_registry.async_get("vacuum.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("vacuum.test_b") + assert entry + assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize(("count", "vacuum_config"), [(1, {"start": []})]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) @pytest.mark.parametrize( ("extra_config", "supported_features"), [ @@ -813,10 +967,10 @@ async def test_empty_action_config( setup_test_vacuum_with_extra_config, ) -> None: """Test configuration with empty script.""" - await common.async_start(hass, _TEST_VACUUM) + await common.async_start(hass, TEST_ENTITY_ID) await hass.async_block_till_done() - state = hass.states.get(_TEST_VACUUM) + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["supported_features"] == ( VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features ) diff --git a/tests/components/tensorflow/__init__.py b/tests/components/tensorflow/__init__.py new file mode 100644 index 00000000000..458de30c9fa --- /dev/null +++ b/tests/components/tensorflow/__init__.py @@ -0,0 +1 @@ +"""TensorFlow component tests.""" diff --git a/tests/components/tensorflow/test_image_processing.py b/tests/components/tensorflow/test_image_processing.py new file mode 100644 index 00000000000..06199b9c60c --- /dev/null +++ b/tests/components/tensorflow/test_image_processing.py @@ -0,0 +1,40 @@ +"""Tensorflow test.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAINN +from homeassistant.components.tensorflow import CONF_GRAPH, DOMAIN as TENSORFLOW_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_MODEL, CONF_PLATFORM, CONF_SOURCE +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", tensorflow=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + IMAGE_PROCESSING_DOMAINN, + { + IMAGE_PROCESSING_DOMAINN: [ + { + CONF_PLATFORM: TENSORFLOW_DOMAIN, + CONF_SOURCE: [ + {CONF_ENTITY_ID: "camera.test_camera"}, + ], + CONF_MODEL: { + CONF_GRAPH: ".", + }, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{TENSORFLOW_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/tesla_fleet/__init__.py b/tests/components/tesla_fleet/__init__.py index 78159402bff..c51cd83ee66 100644 --- a/tests/components/tesla_fleet/__init__.py +++ b/tests/components/tesla_fleet/__init__.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.application_credentials import ( ClientCredential, diff --git a/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr index 4e34f586280..96de02d77d6 100644 --- a/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Backup capable', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backup_capable', 'unique_id': '123456-backup_capable', @@ -74,6 +75,7 @@ 'original_name': 'Grid services active', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_active', 'unique_id': '123456-grid_services_active', @@ -121,6 +123,7 @@ 'original_name': 'Grid services enabled', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'components_grid_services_enabled', 'unique_id': '123456-components_grid_services_enabled', @@ -168,6 +171,7 @@ 'original_name': 'Storm watch active', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storm_mode_active', 'unique_id': '123456-storm_mode_active', @@ -215,6 +219,7 @@ 'original_name': 'Battery heater', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_heater_on', 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_heater_on', @@ -263,6 +268,7 @@ 'original_name': 'Cabin overheat protection actively cooling', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection_actively_cooling', @@ -311,6 +317,7 @@ 'original_name': 'Charge cable', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', 'unique_id': 'LRWXF7EK4KC700000-charge_state_conn_charge_cable', @@ -359,6 +366,7 @@ 'original_name': 'Charger has multiple phases', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_phases', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_phases', @@ -406,6 +414,7 @@ 'original_name': 'Dashcam', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dashcam_state', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_dashcam_state', @@ -454,6 +463,7 @@ 'original_name': 'Front driver door', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_df', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_df', @@ -502,6 +512,7 @@ 'original_name': 'Front driver window', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fd_window', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_fd_window', @@ -550,6 +561,7 @@ 'original_name': 'Front passenger door', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pf', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_pf', @@ -598,6 +610,7 @@ 'original_name': 'Front passenger window', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fp_window', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_fp_window', @@ -646,6 +659,7 @@ 'original_name': 'Preconditioning', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_is_preconditioning', 'unique_id': 'LRWXF7EK4KC700000-climate_state_is_preconditioning', @@ -693,6 +707,7 @@ 'original_name': 'Preconditioning enabled', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_preconditioning_enabled', 'unique_id': 'LRWXF7EK4KC700000-charge_state_preconditioning_enabled', @@ -740,6 +755,7 @@ 'original_name': 'Rear driver door', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dr', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_dr', @@ -788,6 +804,7 @@ 'original_name': 'Rear driver window', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rd_window', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rd_window', @@ -836,6 +853,7 @@ 'original_name': 'Rear passenger door', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pr', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_pr', @@ -884,6 +902,7 @@ 'original_name': 'Rear passenger window', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rp_window', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rp_window', @@ -932,6 +951,7 @@ 'original_name': 'Scheduled charging pending', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_scheduled_charging_pending', 'unique_id': 'LRWXF7EK4KC700000-charge_state_scheduled_charging_pending', @@ -979,6 +999,7 @@ 'original_name': 'Status', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state', 'unique_id': 'LRWXF7EK4KC700000-state', @@ -1027,6 +1048,7 @@ 'original_name': 'Tire pressure warning front left', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fl', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_fl', @@ -1075,6 +1097,7 @@ 'original_name': 'Tire pressure warning front right', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fr', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_fr', @@ -1123,6 +1146,7 @@ 'original_name': 'Tire pressure warning rear left', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rl', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_rl', @@ -1171,6 +1195,7 @@ 'original_name': 'Tire pressure warning rear right', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rr', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_rr', @@ -1219,6 +1244,7 @@ 'original_name': 'Trip charging', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_trip_charging', 'unique_id': 'LRWXF7EK4KC700000-charge_state_trip_charging', @@ -1266,6 +1292,7 @@ 'original_name': 'User present', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_is_user_present', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_is_user_present', diff --git a/tests/components/tesla_fleet/snapshots/test_button.ambr b/tests/components/tesla_fleet/snapshots/test_button.ambr index 145b10112b3..bb0e120a96f 100644 --- a/tests/components/tesla_fleet/snapshots/test_button.ambr +++ b/tests/components/tesla_fleet/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Flash lights', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flash_lights', 'unique_id': 'LRWXF7EK4KC700000-flash_lights', @@ -74,6 +75,7 @@ 'original_name': 'Homelink', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'homelink', 'unique_id': 'LRWXF7EK4KC700000-homelink', @@ -121,6 +123,7 @@ 'original_name': 'Honk horn', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'honk', 'unique_id': 'LRWXF7EK4KC700000-honk', @@ -168,6 +171,7 @@ 'original_name': 'Keyless driving', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'enable_keyless_driving', 'unique_id': 'LRWXF7EK4KC700000-enable_keyless_driving', @@ -215,6 +219,7 @@ 'original_name': 'Play fart', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boombox', 'unique_id': 'LRWXF7EK4KC700000-boombox', @@ -262,6 +267,7 @@ 'original_name': 'Wake', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wake', 'unique_id': 'LRWXF7EK4KC700000-wake', diff --git a/tests/components/tesla_fleet/snapshots/test_climate.ambr b/tests/components/tesla_fleet/snapshots/test_climate.ambr index f3b36730c3f..0f1a2beb113 100644 --- a/tests/components/tesla_fleet/snapshots/test_climate.ambr +++ b/tests/components/tesla_fleet/snapshots/test_climate.ambr @@ -36,6 +36,7 @@ 'original_name': 'Cabin overheat protection', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'climate_state_cabin_overheat_protection', 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', @@ -107,6 +108,7 @@ 'original_name': 'Climate', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': , 'unique_id': 'LRWXF7EK4KC700000-driver_temp', @@ -179,6 +181,7 @@ 'original_name': 'Cabin overheat protection', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'climate_state_cabin_overheat_protection', 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', @@ -249,6 +252,7 @@ 'original_name': 'Climate', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': , 'unique_id': 'LRWXF7EK4KC700000-driver_temp', @@ -321,6 +325,7 @@ 'original_name': 'Cabin overheat protection', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'climate_state_cabin_overheat_protection', 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', @@ -391,6 +396,7 @@ 'original_name': 'Climate', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': , 'unique_id': 'LRWXF7EK4KC700000-driver_temp', diff --git a/tests/components/tesla_fleet/snapshots/test_cover.ambr b/tests/components/tesla_fleet/snapshots/test_cover.ambr index ed6969262f1..a721e899a26 100644 --- a/tests/components/tesla_fleet/snapshots/test_cover.ambr +++ b/tests/components/tesla_fleet/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge port door', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'charge_state_charge_port_door_open', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', @@ -76,6 +77,7 @@ 'original_name': 'Frunk', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_ft', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', @@ -125,6 +127,7 @@ 'original_name': 'Sunroof', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_sun_roof_state', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', @@ -174,6 +177,7 @@ 'original_name': 'Trunk', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_rt', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', @@ -223,6 +227,7 @@ 'original_name': 'Windows', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'windows', 'unique_id': 'LRWXF7EK4KC700000-windows', @@ -272,6 +277,7 @@ 'original_name': 'Charge port door', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'charge_state_charge_port_door_open', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', @@ -321,6 +327,7 @@ 'original_name': 'Frunk', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_ft', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', @@ -370,6 +377,7 @@ 'original_name': 'Sunroof', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_sun_roof_state', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', @@ -419,6 +427,7 @@ 'original_name': 'Trunk', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_rt', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', @@ -468,6 +477,7 @@ 'original_name': 'Windows', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'windows', 'unique_id': 'LRWXF7EK4KC700000-windows', @@ -517,6 +527,7 @@ 'original_name': 'Charge port door', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_door_open', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', @@ -566,6 +577,7 @@ 'original_name': 'Frunk', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_ft', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', @@ -615,6 +627,7 @@ 'original_name': 'Sunroof', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_sun_roof_state', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', @@ -664,6 +677,7 @@ 'original_name': 'Trunk', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rt', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', @@ -713,6 +727,7 @@ 'original_name': 'Windows', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'windows', 'unique_id': 'LRWXF7EK4KC700000-windows', diff --git a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr index dc142c4ffeb..879c50b15bb 100644 --- a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr +++ b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': 'Location', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'LRWXF7EK4KC700000-location', @@ -78,6 +79,7 @@ 'original_name': 'Route', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'route', 'unique_id': 'LRWXF7EK4KC700000-route', diff --git a/tests/components/tesla_fleet/snapshots/test_lock.ambr b/tests/components/tesla_fleet/snapshots/test_lock.ambr index e98ad09caad..4c7c85fd2e5 100644 --- a/tests/components/tesla_fleet/snapshots/test_lock.ambr +++ b/tests/components/tesla_fleet/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge cable lock', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_latch', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_latch', @@ -75,6 +76,7 @@ 'original_name': 'Lock', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_locked', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_locked', diff --git a/tests/components/tesla_fleet/snapshots/test_media_player.ambr b/tests/components/tesla_fleet/snapshots/test_media_player.ambr index 77c46faedd7..ccd39ff33ac 100644 --- a/tests/components/tesla_fleet/snapshots/test_media_player.ambr +++ b/tests/components/tesla_fleet/snapshots/test_media_player.ambr @@ -28,6 +28,7 @@ 'original_name': 'Media player', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'media', 'unique_id': 'LRWXF7EK4KC700000-media', @@ -107,6 +108,7 @@ 'original_name': 'Media player', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'media', 'unique_id': 'LRWXF7EK4KC700000-media', diff --git a/tests/components/tesla_fleet/snapshots/test_number.ambr b/tests/components/tesla_fleet/snapshots/test_number.ambr index a3fccf3a45a..926c2f23ce8 100644 --- a/tests/components/tesla_fleet/snapshots/test_number.ambr +++ b/tests/components/tesla_fleet/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Backup reserve', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backup_reserve_percent', 'unique_id': '123456-backup_reserve_percent', @@ -91,6 +92,7 @@ 'original_name': 'Off-grid reserve', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'off_grid_vehicle_charging_reserve_percent', 'unique_id': '123456-off_grid_vehicle_charging_reserve_percent', @@ -150,6 +152,7 @@ 'original_name': 'Charge current', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_current_request', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_current_request', @@ -208,6 +211,7 @@ 'original_name': 'Charge limit', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_limit_soc', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_limit_soc', diff --git a/tests/components/tesla_fleet/snapshots/test_select.ambr b/tests/components/tesla_fleet/snapshots/test_select.ambr index 171b52decf1..7e698a088be 100644 --- a/tests/components/tesla_fleet/snapshots/test_select.ambr +++ b/tests/components/tesla_fleet/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Allow export', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'components_customer_preferred_export_rule', 'unique_id': '123456-components_customer_preferred_export_rule', @@ -91,6 +92,7 @@ 'original_name': 'Operation mode', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'default_real_mode', 'unique_id': '123456-default_real_mode', @@ -150,6 +152,7 @@ 'original_name': 'Seat heater front left', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_left', 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_left', @@ -210,6 +213,7 @@ 'original_name': 'Seat heater front right', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_right', 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_right', @@ -270,6 +274,7 @@ 'original_name': 'Seat heater rear center', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_center', 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_center', @@ -330,6 +335,7 @@ 'original_name': 'Seat heater rear left', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_left', 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_left', @@ -390,6 +396,7 @@ 'original_name': 'Seat heater rear right', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_right', 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_right', @@ -450,6 +457,7 @@ 'original_name': 'Seat heater third row left', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_third_row_left', 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_left', @@ -510,6 +518,7 @@ 'original_name': 'Seat heater third row right', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_third_row_right', 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_right', @@ -569,6 +578,7 @@ 'original_name': 'Steering wheel heater', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_steering_wheel_heat_level', 'unique_id': 'LRWXF7EK4KC700000-climate_state_steering_wheel_heat_level', diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index f7349c9e2d8..c251468edc4 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -35,6 +35,7 @@ 'original_name': 'Battery charged', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_battery_charge', 'unique_id': '123456-total_battery_charge', @@ -109,6 +110,7 @@ 'original_name': 'Battery discharged', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_battery_discharge', 'unique_id': '123456-total_battery_discharge', @@ -183,6 +185,7 @@ 'original_name': 'Battery exported', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_energy_exported', 'unique_id': '123456-battery_energy_exported', @@ -257,6 +260,7 @@ 'original_name': 'Battery imported from generator', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_energy_imported_from_generator', 'unique_id': '123456-battery_energy_imported_from_generator', @@ -331,6 +335,7 @@ 'original_name': 'Battery imported from grid', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_energy_imported_from_grid', 'unique_id': '123456-battery_energy_imported_from_grid', @@ -405,6 +410,7 @@ 'original_name': 'Battery imported from solar', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_energy_imported_from_solar', 'unique_id': '123456-battery_energy_imported_from_solar', @@ -479,6 +485,7 @@ 'original_name': 'Battery power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_power', 'unique_id': '123456-battery_power', @@ -553,6 +560,7 @@ 'original_name': 'Consumer imported from battery', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumer_energy_imported_from_battery', 'unique_id': '123456-consumer_energy_imported_from_battery', @@ -627,6 +635,7 @@ 'original_name': 'Consumer imported from generator', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumer_energy_imported_from_generator', 'unique_id': '123456-consumer_energy_imported_from_generator', @@ -701,6 +710,7 @@ 'original_name': 'Consumer imported from grid', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumer_energy_imported_from_grid', 'unique_id': '123456-consumer_energy_imported_from_grid', @@ -775,6 +785,7 @@ 'original_name': 'Consumer imported from solar', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumer_energy_imported_from_solar', 'unique_id': '123456-consumer_energy_imported_from_solar', @@ -849,6 +860,7 @@ 'original_name': 'Energy left', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_left', 'unique_id': '123456-energy_left', @@ -923,6 +935,7 @@ 'original_name': 'Generator exported', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'generator_energy_exported', 'unique_id': '123456-generator_energy_exported', @@ -997,6 +1010,7 @@ 'original_name': 'Generator power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'generator_power', 'unique_id': '123456-generator_power', @@ -1071,6 +1085,7 @@ 'original_name': 'Grid exported', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_grid_energy_exported', 'unique_id': '123456-total_grid_energy_exported', @@ -1145,6 +1160,7 @@ 'original_name': 'Grid exported from battery', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_energy_exported_from_battery', 'unique_id': '123456-grid_energy_exported_from_battery', @@ -1219,6 +1235,7 @@ 'original_name': 'Grid exported from generator', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_energy_exported_from_generator', 'unique_id': '123456-grid_energy_exported_from_generator', @@ -1293,6 +1310,7 @@ 'original_name': 'Grid exported from solar', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_energy_exported_from_solar', 'unique_id': '123456-grid_energy_exported_from_solar', @@ -1367,6 +1385,7 @@ 'original_name': 'Grid imported', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_energy_imported', 'unique_id': '123456-grid_energy_imported', @@ -1441,6 +1460,7 @@ 'original_name': 'Grid power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_power', 'unique_id': '123456-grid_power', @@ -1515,6 +1535,7 @@ 'original_name': 'Grid services exported', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_energy_exported', 'unique_id': '123456-grid_services_energy_exported', @@ -1589,6 +1610,7 @@ 'original_name': 'Grid services imported', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_energy_imported', 'unique_id': '123456-grid_services_energy_imported', @@ -1663,6 +1685,7 @@ 'original_name': 'Grid services power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_power', 'unique_id': '123456-grid_services_power', @@ -1737,6 +1760,7 @@ 'original_name': 'Grid Status', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'island_status', 'unique_id': '123456-island_status', @@ -1821,6 +1845,7 @@ 'original_name': 'Home usage', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_home_usage', 'unique_id': '123456-total_home_usage', @@ -1895,6 +1920,7 @@ 'original_name': 'Load power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'load_power', 'unique_id': '123456-load_power', @@ -1966,6 +1992,7 @@ 'original_name': 'Percentage charged', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'percentage_charged', 'unique_id': '123456-percentage_charged', @@ -2040,6 +2067,7 @@ 'original_name': 'Solar exported', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_energy_exported', 'unique_id': '123456-solar_energy_exported', @@ -2114,6 +2142,7 @@ 'original_name': 'Solar generated', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_solar_generation', 'unique_id': '123456-total_solar_generation', @@ -2188,6 +2217,7 @@ 'original_name': 'Solar power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_power', 'unique_id': '123456-solar_power', @@ -2262,6 +2292,7 @@ 'original_name': 'Total pack energy', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_pack_energy', 'unique_id': '123456-total_pack_energy', @@ -2328,6 +2359,7 @@ 'original_name': 'version', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'version', 'unique_id': '123456-version', @@ -2388,6 +2420,7 @@ 'original_name': 'VPP backup reserve', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vpp_backup_reserve_percent', 'unique_id': '123456-vpp_backup_reserve_percent', @@ -2454,6 +2487,7 @@ 'original_name': 'Battery level', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_level', 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_level', @@ -2528,6 +2562,7 @@ 'original_name': 'Battery range', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_range', 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_range', @@ -2594,6 +2629,7 @@ 'original_name': 'Charge cable', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', 'unique_id': 'LRWXF7EK4KC700000-charge_state_conn_charge_cable', @@ -2659,6 +2695,7 @@ 'original_name': 'Charge energy added', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_energy_added', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_energy_added', @@ -2721,6 +2758,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -2730,6 +2770,7 @@ 'original_name': 'Charge rate', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_rate', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_rate', @@ -2749,7 +2790,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_charge_rate-statealt] @@ -2765,7 +2806,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_charger_current-entry] @@ -2792,12 +2833,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charger current', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_actual_current', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_actual_current', @@ -2860,12 +2905,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charger power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_power', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_power', @@ -2928,12 +2977,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charger voltage', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_voltage', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_voltage', @@ -3009,6 +3062,7 @@ 'original_name': 'Charging', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRWXF7EK4KC700000-charge_state_charging_state', @@ -3083,6 +3137,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3092,6 +3149,7 @@ 'original_name': 'Distance to arrival', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_miles_to_arrival', 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_miles_to_arrival', @@ -3111,7 +3169,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.063555', + 'state': '0.063554603904', }) # --- # name: test_sensors[sensor.test_distance_to_arrival-statealt] @@ -3127,7 +3185,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_driver_temperature_setting-entry] @@ -3163,6 +3221,7 @@ 'original_name': 'Driver temperature setting', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_driver_temp_setting', 'unique_id': 'LRWXF7EK4KC700000-climate_state_driver_temp_setting', @@ -3237,6 +3296,7 @@ 'original_name': 'Estimate battery range', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_est_battery_range', 'unique_id': 'LRWXF7EK4KC700000-charge_state_est_battery_range', @@ -3303,6 +3363,7 @@ 'original_name': 'Fast charger type', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_fast_charger_type', 'unique_id': 'LRWXF7EK4KC700000-charge_state_fast_charger_type', @@ -3371,6 +3432,7 @@ 'original_name': 'Ideal battery range', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_ideal_battery_range', 'unique_id': 'LRWXF7EK4KC700000-charge_state_ideal_battery_range', @@ -3442,6 +3504,7 @@ 'original_name': 'Inside temperature', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_inside_temp', 'unique_id': 'LRWXF7EK4KC700000-climate_state_inside_temp', @@ -3516,6 +3579,7 @@ 'original_name': 'Odometer', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_odometer', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_odometer', @@ -3587,6 +3651,7 @@ 'original_name': 'Outside temperature', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_outside_temp', 'unique_id': 'LRWXF7EK4KC700000-climate_state_outside_temp', @@ -3658,6 +3723,7 @@ 'original_name': 'Passenger temperature setting', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_passenger_temp_setting', 'unique_id': 'LRWXF7EK4KC700000-climate_state_passenger_temp_setting', @@ -3720,12 +3786,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_power', 'unique_id': 'LRWXF7EK4KC700000-drive_state_power', @@ -3799,6 +3869,7 @@ 'original_name': 'Shift state', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_shift_state', 'unique_id': 'LRWXF7EK4KC700000-drive_state_shift_state', @@ -3869,6 +3940,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3878,6 +3952,7 @@ 'original_name': 'Speed', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_speed', 'unique_id': 'LRWXF7EK4KC700000-drive_state_speed', @@ -3897,7 +3972,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_speed-statealt] @@ -3913,7 +3988,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_state_of_charge_at_arrival-entry] @@ -3946,6 +4021,7 @@ 'original_name': 'State of charge at arrival', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_energy_at_arrival', 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_energy_at_arrival', @@ -4012,6 +4088,7 @@ 'original_name': 'Time to arrival', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_minutes_to_arrival', 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_minutes_to_arrival', @@ -4074,6 +4151,7 @@ 'original_name': 'Time to full charge', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_minutes_to_full_charge', 'unique_id': 'LRWXF7EK4KC700000-charge_state_minutes_to_full_charge', @@ -4144,6 +4222,7 @@ 'original_name': 'Tire pressure front left', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fl', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_fl', @@ -4218,6 +4297,7 @@ 'original_name': 'Tire pressure front right', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fr', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_fr', @@ -4292,6 +4372,7 @@ 'original_name': 'Tire pressure rear left', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rl', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_rl', @@ -4366,6 +4447,7 @@ 'original_name': 'Tire pressure rear right', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rr', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_rr', @@ -4428,12 +4510,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Traffic delay', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_traffic_minutes_delay', 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_traffic_minutes_delay', @@ -4502,6 +4588,7 @@ 'original_name': 'Usable battery level', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_usable_battery_level', 'unique_id': 'LRWXF7EK4KC700000-charge_state_usable_battery_level', @@ -4568,6 +4655,7 @@ 'original_name': 'Fault state code', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_fault_state', 'unique_id': '123456-abd-123-wall_connector_fault_state', @@ -4628,6 +4716,7 @@ 'original_name': 'Fault state code', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_fault_state', 'unique_id': '123456-bcd-234-wall_connector_fault_state', @@ -4696,6 +4785,7 @@ 'original_name': 'Power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_power', 'unique_id': '123456-abd-123-wall_connector_power', @@ -4770,6 +4860,7 @@ 'original_name': 'Power', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_power', 'unique_id': '123456-bcd-234-wall_connector_power', @@ -4836,6 +4927,7 @@ 'original_name': 'State code', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_state', 'unique_id': '123456-abd-123-wall_connector_state', @@ -4896,6 +4988,7 @@ 'original_name': 'State code', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_state', 'unique_id': '123456-bcd-234-wall_connector_state', @@ -4956,6 +5049,7 @@ 'original_name': 'Vehicle', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vin', 'unique_id': '123456-abd-123-vin', @@ -5016,6 +5110,7 @@ 'original_name': 'Vehicle', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vin', 'unique_id': '123456-bcd-234-vin', diff --git a/tests/components/tesla_fleet/snapshots/test_switch.ambr b/tests/components/tesla_fleet/snapshots/test_switch.ambr index 2ea3bcc5ee5..b9efff6f23b 100644 --- a/tests/components/tesla_fleet/snapshots/test_switch.ambr +++ b/tests/components/tesla_fleet/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Allow charging from grid', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'components_disallow_charge_from_grid_with_solar_installed', 'unique_id': '123456-components_disallow_charge_from_grid_with_solar_installed', @@ -75,6 +76,7 @@ 'original_name': 'Storm watch', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'user_settings_storm_mode_enabled', 'unique_id': '123456-user_settings_storm_mode_enabled', @@ -123,6 +125,7 @@ 'original_name': 'Auto seat climate left', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_left', 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_left', @@ -171,6 +174,7 @@ 'original_name': 'Auto seat climate right', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_right', 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_right', @@ -219,6 +223,7 @@ 'original_name': 'Auto steering wheel heater', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_steering_wheel_heat', 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_steering_wheel_heat', @@ -267,6 +272,7 @@ 'original_name': 'Charge', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRWXF7EK4KC700000-charge_state_user_charge_enable_request', @@ -315,6 +321,7 @@ 'original_name': 'Defrost', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_defrost_mode', 'unique_id': 'LRWXF7EK4KC700000-climate_state_defrost_mode', @@ -363,6 +370,7 @@ 'original_name': 'Sentry mode', 'platform': 'tesla_fleet', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_sentry_mode', 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sentry_mode', diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index d43f7448379..9eb12961dfa 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -4,7 +4,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import NotOnWhitelistFault from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS diff --git a/tests/components/tesla_fleet/test_cover.py b/tests/components/tesla_fleet/test_cover.py index 15d14f34a87..045e5cfabb9 100644 --- a/tests/components/tesla_fleet/test_cover.py +++ b/tests/components/tesla_fleet/test_cover.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.cover import ( diff --git a/tests/components/tesla_fleet/test_lock.py b/tests/components/tesla_fleet/test_lock.py index ac9a7b49b55..a8aec27100c 100644 --- a/tests/components/tesla_fleet/test_lock.py +++ b/tests/components/tesla_fleet/test_lock.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.lock import ( diff --git a/tests/components/tesla_fleet/test_media_player.py b/tests/components/tesla_fleet/test_media_player.py index b2900d96c80..3233246b8b5 100644 --- a/tests/components/tesla_fleet/test_media_player.py +++ b/tests/components/tesla_fleet/test_media_player.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.media_player import ( diff --git a/tests/components/tesla_fleet/test_number.py b/tests/components/tesla_fleet/test_number.py index 4ade98852c8..66734c27f6f 100644 --- a/tests/components/tesla_fleet/test_number.py +++ b/tests/components/tesla_fleet/test_number.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.number import ( diff --git a/tests/components/tesla_fleet/test_select.py b/tests/components/tesla_fleet/test_select.py index f06d67041c9..5aa05ab7976 100644 --- a/tests/components/tesla_fleet/test_select.py +++ b/tests/components/tesla_fleet/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode from tesla_fleet_api.exceptions import VehicleOffline diff --git a/tests/components/tesla_fleet/test_switch.py b/tests/components/tesla_fleet/test_switch.py index 022c3a0ab18..dcdf66b7cc1 100644 --- a/tests/components/tesla_fleet/test_switch.py +++ b/tests/components/tesla_fleet/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.switch import ( diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index e4499d6e308..4cb03f2bb1e 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -88,7 +88,23 @@ async def create_wall_connector_entry( def get_vitals_mock() -> Vitals: """Get mocked vitals object.""" - return MagicMock(auto_spec=Vitals) + mock = MagicMock(auto_spec=Vitals) + mock.evse_state = 1 + mock.handle_temp_c = 25.51 + mock.pcba_temp_c = 30.5 + mock.mcu_temp_c = 42.0 + mock.grid_v = 230.15 + mock.grid_hz = 50.021 + mock.voltageA_v = 230.1 + mock.voltageB_v = 231 + mock.voltageC_v = 232.1 + mock.currentA_a = 10 + mock.currentB_a = 11.1 + mock.currentC_a = 12 + mock.session_energy_wh = 1234.56 + mock.contactor_closed = False + mock.vehicle_connected = True + return mock def get_lifetime_mock() -> Lifetime: diff --git a/tests/components/tesla_wall_connector/test_binary_sensor.py b/tests/components/tesla_wall_connector/test_binary_sensor.py index 22100bbb1c1..3990369262d 100644 --- a/tests/components/tesla_wall_connector/test_binary_sensor.py +++ b/tests/components/tesla_wall_connector/test_binary_sensor.py @@ -23,8 +23,6 @@ async def test_sensors(hass: HomeAssistant) -> None: ] mock_vitals_first_update = get_vitals_mock() - mock_vitals_first_update.contactor_closed = False - mock_vitals_first_update.vehicle_connected = True mock_vitals_second_update = get_vitals_mock() mock_vitals_second_update.contactor_closed = True diff --git a/tests/components/tesla_wall_connector/test_init.py b/tests/components/tesla_wall_connector/test_init.py index 2b37924b2e4..fbb3abc1746 100644 --- a/tests/components/tesla_wall_connector/test_init.py +++ b/tests/components/tesla_wall_connector/test_init.py @@ -5,13 +5,15 @@ from tesla_wall_connector.exceptions import WallConnectorConnectionError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import create_wall_connector_entry +from .conftest import create_wall_connector_entry, get_lifetime_mock, get_vitals_mock async def test_init_success(hass: HomeAssistant) -> None: """Test setup and that we get the device info, including firmware version.""" - entry = await create_wall_connector_entry(hass) + entry = await create_wall_connector_entry( + hass, vitals_data=get_vitals_mock(), lifetime_data=get_lifetime_mock() + ) assert entry.state is ConfigEntryState.LOADED @@ -28,8 +30,9 @@ async def test_init_while_offline(hass: HomeAssistant) -> None: async def test_load_unload(hass: HomeAssistant) -> None: """Config entry can be unloaded.""" - entry = await create_wall_connector_entry(hass) - + entry = await create_wall_connector_entry( + hass, vitals_data=get_vitals_mock(), lifetime_data=get_lifetime_mock() + ) assert entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py index 62eca46c388..56bed9edbb3 100644 --- a/tests/components/tesla_wall_connector/test_sensor.py +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -33,7 +33,7 @@ async def test_sensors(hass: HomeAssistant) -> None: "sensor.tesla_wall_connector_grid_frequency", "50.021", "49.981" ), EntityAndExpectedValues( - "sensor.tesla_wall_connector_energy", "988.022", "989.000" + "sensor.tesla_wall_connector_energy", "988.022", "989.0" ), EntityAndExpectedValues( "sensor.tesla_wall_connector_phase_a_current", "10", "7" @@ -59,19 +59,6 @@ async def test_sensors(hass: HomeAssistant) -> None: ] mock_vitals_first_update = get_vitals_mock() - mock_vitals_first_update.evse_state = 1 - mock_vitals_first_update.handle_temp_c = 25.51 - mock_vitals_first_update.pcba_temp_c = 30.5 - mock_vitals_first_update.mcu_temp_c = 42.0 - mock_vitals_first_update.grid_v = 230.15 - mock_vitals_first_update.grid_hz = 50.021 - mock_vitals_first_update.voltageA_v = 230.1 - mock_vitals_first_update.voltageB_v = 231 - mock_vitals_first_update.voltageC_v = 232.1 - mock_vitals_first_update.currentA_a = 10 - mock_vitals_first_update.currentB_a = 11.1 - mock_vitals_first_update.currentC_a = 12 - mock_vitals_first_update.session_energy_wh = 1234.56 mock_vitals_second_update = get_vitals_mock() mock_vitals_second_update.evse_state = 3 diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index d957bdedcf4..06ec0a60434 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Backup capable', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backup_capable', 'unique_id': '123456-backup_capable', @@ -74,6 +75,7 @@ 'original_name': 'Grid services active', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_active', 'unique_id': '123456-grid_services_active', @@ -121,6 +123,7 @@ 'original_name': 'Grid services enabled', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'components_grid_services_enabled', 'unique_id': '123456-components_grid_services_enabled', @@ -168,6 +171,7 @@ 'original_name': 'Grid status', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_status', 'unique_id': '123456-grid_status', @@ -216,6 +220,7 @@ 'original_name': 'Storm watch active', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storm_mode_active', 'unique_id': '123456-storm_mode_active', @@ -263,6 +268,7 @@ 'original_name': 'Automatic blind spot camera', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'automatic_blind_spot_camera', 'unique_id': 'LRW3F7EK4NC700000-automatic_blind_spot_camera', @@ -310,6 +316,7 @@ 'original_name': 'Automatic emergency braking off', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'automatic_emergency_braking_off', 'unique_id': 'LRW3F7EK4NC700000-automatic_emergency_braking_off', @@ -357,6 +364,7 @@ 'original_name': 'Battery heater', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_heater_on', 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_heater_on', @@ -405,6 +413,7 @@ 'original_name': 'Blind spot collision warning chime', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'blind_spot_collision_warning_chime', 'unique_id': 'LRW3F7EK4NC700000-blind_spot_collision_warning_chime', @@ -452,6 +461,7 @@ 'original_name': 'BMS full charge', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bms_full_charge_complete', 'unique_id': 'LRW3F7EK4NC700000-bms_full_charge_complete', @@ -499,6 +509,7 @@ 'original_name': 'Brake pedal', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brake_pedal', 'unique_id': 'LRW3F7EK4NC700000-brake_pedal', @@ -546,6 +557,7 @@ 'original_name': 'Cabin overheat protection active', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection_actively_cooling', @@ -594,6 +606,7 @@ 'original_name': 'Cellular', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cellular', 'unique_id': 'LRW3F7EK4NC700000-cellular', @@ -642,6 +655,7 @@ 'original_name': 'Charge cable', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', 'unique_id': 'LRW3F7EK4NC700000-charge_state_conn_charge_cable', @@ -659,7 +673,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_charge_enable_request-entry] @@ -690,6 +704,7 @@ 'original_name': 'Charge enable request', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_enable_request', 'unique_id': 'LRW3F7EK4NC700000-charge_enable_request', @@ -737,6 +752,7 @@ 'original_name': 'Charge port cold weather mode', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_port_cold_weather_mode', 'unique_id': 'LRW3F7EK4NC700000-charge_port_cold_weather_mode', @@ -784,6 +800,7 @@ 'original_name': 'Charger has multiple phases', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_phases', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_phases', @@ -831,6 +848,7 @@ 'original_name': 'Dashcam', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dashcam_state', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dashcam_state', @@ -879,6 +897,7 @@ 'original_name': 'DC to DC converter', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dc_dc_enable', 'unique_id': 'LRW3F7EK4NC700000-dc_dc_enable', @@ -926,6 +945,7 @@ 'original_name': 'Defrost for preconditioning', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'defrost_for_preconditioning', 'unique_id': 'LRW3F7EK4NC700000-defrost_for_preconditioning', @@ -973,6 +993,7 @@ 'original_name': 'Drive rail', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_rail', 'unique_id': 'LRW3F7EK4NC700000-drive_rail', @@ -1020,6 +1041,7 @@ 'original_name': 'Driver seat belt', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'driver_seat_belt', 'unique_id': 'LRW3F7EK4NC700000-driver_seat_belt', @@ -1067,6 +1089,7 @@ 'original_name': 'Driver seat occupied', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'driver_seat_occupied', 'unique_id': 'LRW3F7EK4NC700000-driver_seat_occupied', @@ -1114,6 +1137,7 @@ 'original_name': 'Emergency lane departure avoidance', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'emergency_lane_departure_avoidance', 'unique_id': 'LRW3F7EK4NC700000-emergency_lane_departure_avoidance', @@ -1161,6 +1185,7 @@ 'original_name': 'European vehicle', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'europe_vehicle', 'unique_id': 'LRW3F7EK4NC700000-europe_vehicle', @@ -1208,6 +1233,7 @@ 'original_name': 'Fast charger present', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fast_charger_present', 'unique_id': 'LRW3F7EK4NC700000-fast_charger_present', @@ -1255,6 +1281,7 @@ 'original_name': 'Front driver door', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_df', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_df', @@ -1303,6 +1330,7 @@ 'original_name': 'Front driver window', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fd_window', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fd_window', @@ -1351,6 +1379,7 @@ 'original_name': 'Front passenger door', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pf', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pf', @@ -1399,6 +1428,7 @@ 'original_name': 'Front passenger window', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fp_window', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fp_window', @@ -1447,6 +1477,7 @@ 'original_name': 'GPS state', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gps_state', 'unique_id': 'LRW3F7EK4NC700000-gps_state', @@ -1495,6 +1526,7 @@ 'original_name': 'Guest mode enabled', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'guest_mode_enabled', 'unique_id': 'LRW3F7EK4NC700000-guest_mode_enabled', @@ -1514,6 +1546,54 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_hazard_lights-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_hazard_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hazard lights', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lights_hazards_active', + 'unique_id': 'LRW3F7EK4NC700000-lights_hazards_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_hazard_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Hazard lights', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hazard_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_high_beams-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1542,6 +1622,7 @@ 'original_name': 'High beams', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lights_high_beams', 'unique_id': 'LRW3F7EK4NC700000-lights_high_beams', @@ -1589,6 +1670,7 @@ 'original_name': 'High voltage interlock loop fault', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvil', 'unique_id': 'LRW3F7EK4NC700000-hvil', @@ -1637,6 +1719,7 @@ 'original_name': 'Homelink nearby', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'homelink_nearby', 'unique_id': 'LRW3F7EK4NC700000-homelink_nearby', @@ -1684,6 +1767,7 @@ 'original_name': 'HVAC auto mode', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hvac_auto_mode', 'unique_id': 'LRW3F7EK4NC700000-hvac_auto_mode', @@ -1731,6 +1815,7 @@ 'original_name': 'Located at favorite', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'located_at_favorite', 'unique_id': 'LRW3F7EK4NC700000-located_at_favorite', @@ -1778,6 +1863,7 @@ 'original_name': 'Located at home', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'located_at_home', 'unique_id': 'LRW3F7EK4NC700000-located_at_home', @@ -1825,6 +1911,7 @@ 'original_name': 'Located at work', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'located_at_work', 'unique_id': 'LRW3F7EK4NC700000-located_at_work', @@ -1872,6 +1959,7 @@ 'original_name': 'Offroad lightbar', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'offroad_lightbar_present', 'unique_id': 'LRW3F7EK4NC700000-offroad_lightbar_present', @@ -1919,6 +2007,7 @@ 'original_name': 'Passenger seat belt', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'passenger_seat_belt', 'unique_id': 'LRW3F7EK4NC700000-passenger_seat_belt', @@ -1966,6 +2055,7 @@ 'original_name': 'PIN to Drive enabled', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pin_to_drive_enabled', 'unique_id': 'LRW3F7EK4NC700000-pin_to_drive_enabled', @@ -2013,6 +2103,7 @@ 'original_name': 'Preconditioning', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_is_preconditioning', 'unique_id': 'LRW3F7EK4NC700000-climate_state_is_preconditioning', @@ -2060,6 +2151,7 @@ 'original_name': 'Preconditioning enabled', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_preconditioning_enabled', 'unique_id': 'LRW3F7EK4NC700000-charge_state_preconditioning_enabled', @@ -2107,6 +2199,7 @@ 'original_name': 'Rear display HVAC', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rear_display_hvac_enabled', 'unique_id': 'LRW3F7EK4NC700000-rear_display_hvac_enabled', @@ -2154,6 +2247,7 @@ 'original_name': 'Rear driver door', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dr', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dr', @@ -2202,6 +2296,7 @@ 'original_name': 'Rear driver window', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rd_window', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rd_window', @@ -2250,6 +2345,7 @@ 'original_name': 'Rear passenger door', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pr', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pr', @@ -2298,6 +2394,7 @@ 'original_name': 'Rear passenger window', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rp_window', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rp_window', @@ -2346,6 +2443,7 @@ 'original_name': 'Remote start', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remote_start_enabled', 'unique_id': 'LRW3F7EK4NC700000-remote_start_enabled', @@ -2393,6 +2491,7 @@ 'original_name': 'Right hand drive', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'right_hand_drive', 'unique_id': 'LRW3F7EK4NC700000-right_hand_drive', @@ -2440,6 +2539,7 @@ 'original_name': 'Scheduled charging pending', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_scheduled_charging_pending', 'unique_id': 'LRW3F7EK4NC700000-charge_state_scheduled_charging_pending', @@ -2487,6 +2587,7 @@ 'original_name': 'Seat vent enabled', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'seat_vent_enabled', 'unique_id': 'LRW3F7EK4NC700000-seat_vent_enabled', @@ -2534,6 +2635,7 @@ 'original_name': 'Service mode', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'service_mode', 'unique_id': 'LRW3F7EK4NC700000-service_mode', @@ -2581,6 +2683,7 @@ 'original_name': 'Speed limited', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'speed_limit_mode', 'unique_id': 'LRW3F7EK4NC700000-speed_limit_mode', @@ -2628,6 +2731,7 @@ 'original_name': 'Status', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state', 'unique_id': 'LRW3F7EK4NC700000-state', @@ -2676,6 +2780,7 @@ 'original_name': 'Supercharger session trip planner', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supercharger_session_trip_planner', 'unique_id': 'LRW3F7EK4NC700000-supercharger_session_trip_planner', @@ -2723,6 +2828,7 @@ 'original_name': 'Tire pressure warning front left', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fl', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_fl', @@ -2771,6 +2877,7 @@ 'original_name': 'Tire pressure warning front right', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fr', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_fr', @@ -2819,6 +2926,7 @@ 'original_name': 'Tire pressure warning rear left', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rl', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_rl', @@ -2867,6 +2975,7 @@ 'original_name': 'Tire pressure warning rear right', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rr', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_rr', @@ -2915,6 +3024,7 @@ 'original_name': 'Trip charging', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_trip_charging', 'unique_id': 'LRW3F7EK4NC700000-charge_state_trip_charging', @@ -2962,6 +3072,7 @@ 'original_name': 'User present', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_is_user_present', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_is_user_present', @@ -3010,6 +3121,7 @@ 'original_name': 'Wi-Fi', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi', 'unique_id': 'LRW3F7EK4NC700000-wifi', @@ -3058,6 +3170,7 @@ 'original_name': 'Wiper heat', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wiper_heat_enabled', 'unique_id': 'LRW3F7EK4NC700000-wiper_heat_enabled', @@ -3261,7 +3374,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_enable_request-statealt] @@ -3504,6 +3617,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_hazard_lights-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Hazard lights', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hazard_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_high_beams-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/teslemetry/snapshots/test_button.ambr b/tests/components/teslemetry/snapshots/test_button.ambr index e4e20215020..714d4ed1f6d 100644 --- a/tests/components/teslemetry/snapshots/test_button.ambr +++ b/tests/components/teslemetry/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Flash lights', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flash_lights', 'unique_id': 'LRW3F7EK4NC700000-flash_lights', @@ -74,6 +75,7 @@ 'original_name': 'Homelink', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'homelink', 'unique_id': 'LRW3F7EK4NC700000-homelink', @@ -121,6 +123,7 @@ 'original_name': 'Honk horn', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'honk', 'unique_id': 'LRW3F7EK4NC700000-honk', @@ -168,6 +171,7 @@ 'original_name': 'Keyless driving', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'enable_keyless_driving', 'unique_id': 'LRW3F7EK4NC700000-enable_keyless_driving', @@ -215,6 +219,7 @@ 'original_name': 'Play fart', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boombox', 'unique_id': 'LRW3F7EK4NC700000-boombox', @@ -262,6 +267,7 @@ 'original_name': 'Wake', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wake', 'unique_id': 'LRW3F7EK4NC700000-wake', diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index e0e68f23c79..1aa68b59ee3 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -36,6 +36,7 @@ 'original_name': 'Cabin overheat protection', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'climate_state_cabin_overheat_protection', 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection', @@ -111,6 +112,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': , 'unique_id': 'LRW3F7EK4NC700000-driver_temp', @@ -188,6 +190,7 @@ 'original_name': 'Cabin overheat protection', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'climate_state_cabin_overheat_protection', 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection', @@ -262,6 +265,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': , 'unique_id': 'LRW3F7EK4NC700000-driver_temp', @@ -339,6 +343,7 @@ 'original_name': 'Cabin overheat protection', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_cabin_overheat_protection', 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection', @@ -380,6 +385,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , 'unique_id': 'LRW3F7EK4NC700000-driver_temp', diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index 9548a911cf9..cec35e79fc7 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge port door', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'charge_state_charge_port_door_open', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', @@ -76,6 +77,7 @@ 'original_name': 'Frunk', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_ft', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', @@ -125,6 +127,7 @@ 'original_name': 'Sunroof', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_sun_roof_state', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', @@ -174,6 +177,7 @@ 'original_name': 'Trunk', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_rt', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', @@ -223,6 +227,7 @@ 'original_name': 'Windows', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'windows', 'unique_id': 'LRW3F7EK4NC700000-windows', @@ -272,6 +277,7 @@ 'original_name': 'Charge port door', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'charge_state_charge_port_door_open', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', @@ -321,6 +327,7 @@ 'original_name': 'Frunk', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_ft', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', @@ -370,6 +377,7 @@ 'original_name': 'Trunk', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_rt', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', @@ -419,6 +427,7 @@ 'original_name': 'Windows', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'windows', 'unique_id': 'LRW3F7EK4NC700000-windows', @@ -468,6 +477,7 @@ 'original_name': 'Charge port door', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_door_open', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', @@ -517,6 +527,7 @@ 'original_name': 'Frunk', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_ft', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', @@ -566,6 +577,7 @@ 'original_name': 'Sunroof', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_sun_roof_state', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', @@ -615,6 +627,7 @@ 'original_name': 'Trunk', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rt', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', @@ -664,6 +677,7 @@ 'original_name': 'Windows', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'windows', 'unique_id': 'LRW3F7EK4NC700000-windows', @@ -713,11 +727,11 @@ 'unknown' # --- # name: test_cover_streaming[cover.test_windows-closed] - 'unknown' + 'closed' # --- # name: test_cover_streaming[cover.test_windows-open] - 'unknown' + 'open' # --- # name: test_cover_streaming[cover.test_windows-unknown] - 'unknown' + 'open' # --- diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index b9e381ee42d..c71f818479a 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': 'Location', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'LRW3F7EK4NC700000-location', @@ -78,6 +79,7 @@ 'original_name': 'Route', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'route', 'unique_id': 'LRW3F7EK4NC700000-route', diff --git a/tests/components/teslemetry/snapshots/test_lock.ambr b/tests/components/teslemetry/snapshots/test_lock.ambr index d6b29f0d7d4..e84c00e46de 100644 --- a/tests/components/teslemetry/snapshots/test_lock.ambr +++ b/tests/components/teslemetry/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge cable lock', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_latch', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_latch', @@ -75,6 +76,7 @@ 'original_name': 'Lock', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_locked', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_locked', @@ -123,6 +125,7 @@ 'original_name': 'Charge cable lock', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_latch', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_latch', @@ -171,6 +174,7 @@ 'original_name': 'Lock', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_locked', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_locked', diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr index 7f721b95289..75f482700cc 100644 --- a/tests/components/teslemetry/snapshots/test_media_player.ambr +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -28,6 +28,7 @@ 'original_name': 'Media player', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'media', 'unique_id': 'LRW3F7EK4NC700000-media', @@ -108,6 +109,7 @@ 'original_name': 'Media player', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'media', 'unique_id': 'LRW3F7EK4NC700000-media', diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 2c6705074f3..70d7bfd33a9 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Backup reserve', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backup_reserve_percent', 'unique_id': '123456-backup_reserve_percent', @@ -91,6 +92,7 @@ 'original_name': 'Off-grid reserve', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'off_grid_vehicle_charging_reserve_percent', 'unique_id': '123456-off_grid_vehicle_charging_reserve_percent', @@ -150,6 +152,7 @@ 'original_name': 'Charge current', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_current_request', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_current_request', @@ -208,6 +211,7 @@ 'original_name': 'Charge limit', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_limit_soc', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_limit_soc', diff --git a/tests/components/teslemetry/snapshots/test_select.ambr b/tests/components/teslemetry/snapshots/test_select.ambr index 755a1a82c41..08b70a22569 100644 --- a/tests/components/teslemetry/snapshots/test_select.ambr +++ b/tests/components/teslemetry/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Allow export', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'components_customer_preferred_export_rule', 'unique_id': '123456-components_customer_preferred_export_rule', @@ -91,6 +92,7 @@ 'original_name': 'Operation mode', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'default_real_mode', 'unique_id': '123456-default_real_mode', @@ -150,6 +152,7 @@ 'original_name': 'Seat heater front left', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_left', 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_left', @@ -210,6 +213,7 @@ 'original_name': 'Seat heater front right', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_right', 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_right', @@ -270,6 +274,7 @@ 'original_name': 'Seat heater rear center', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_center', 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_rear_center', @@ -330,6 +335,7 @@ 'original_name': 'Seat heater rear left', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_left', 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_rear_left', @@ -390,6 +396,7 @@ 'original_name': 'Seat heater rear right', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_right', 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_rear_right', @@ -449,6 +456,7 @@ 'original_name': 'Steering wheel heater', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_steering_wheel_heat_level', 'unique_id': 'LRW3F7EK4NC700000-climate_state_steering_wheel_heat_level', diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 3b860039b03..57a0f49d949 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -35,6 +35,7 @@ 'original_name': 'Battery charged', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_battery_charge', 'unique_id': '123456-total_battery_charge', @@ -109,6 +110,7 @@ 'original_name': 'Battery discharged', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_battery_discharge', 'unique_id': '123456-total_battery_discharge', @@ -183,6 +185,7 @@ 'original_name': 'Battery exported', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_energy_exported', 'unique_id': '123456-battery_energy_exported', @@ -257,6 +260,7 @@ 'original_name': 'Battery imported from generator', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_energy_imported_from_generator', 'unique_id': '123456-battery_energy_imported_from_generator', @@ -331,6 +335,7 @@ 'original_name': 'Battery imported from grid', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_energy_imported_from_grid', 'unique_id': '123456-battery_energy_imported_from_grid', @@ -405,6 +410,7 @@ 'original_name': 'Battery imported from solar', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_energy_imported_from_solar', 'unique_id': '123456-battery_energy_imported_from_solar', @@ -479,6 +485,7 @@ 'original_name': 'Battery power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_power', 'unique_id': '123456-battery_power', @@ -553,6 +560,7 @@ 'original_name': 'Consumer imported from battery', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumer_energy_imported_from_battery', 'unique_id': '123456-consumer_energy_imported_from_battery', @@ -627,6 +635,7 @@ 'original_name': 'Consumer imported from generator', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumer_energy_imported_from_generator', 'unique_id': '123456-consumer_energy_imported_from_generator', @@ -701,6 +710,7 @@ 'original_name': 'Consumer imported from grid', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumer_energy_imported_from_grid', 'unique_id': '123456-consumer_energy_imported_from_grid', @@ -775,6 +785,7 @@ 'original_name': 'Consumer imported from solar', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumer_energy_imported_from_solar', 'unique_id': '123456-consumer_energy_imported_from_solar', @@ -849,6 +860,7 @@ 'original_name': 'Energy left', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_left', 'unique_id': '123456-energy_left', @@ -923,6 +935,7 @@ 'original_name': 'Generator exported', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'generator_energy_exported', 'unique_id': '123456-generator_energy_exported', @@ -997,6 +1010,7 @@ 'original_name': 'Generator power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'generator_power', 'unique_id': '123456-generator_power', @@ -1071,6 +1085,7 @@ 'original_name': 'Grid exported', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_grid_energy_exported', 'unique_id': '123456-total_grid_energy_exported', @@ -1145,6 +1160,7 @@ 'original_name': 'Grid exported from battery', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_energy_exported_from_battery', 'unique_id': '123456-grid_energy_exported_from_battery', @@ -1219,6 +1235,7 @@ 'original_name': 'Grid exported from generator', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_energy_exported_from_generator', 'unique_id': '123456-grid_energy_exported_from_generator', @@ -1293,6 +1310,7 @@ 'original_name': 'Grid exported from solar', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_energy_exported_from_solar', 'unique_id': '123456-grid_energy_exported_from_solar', @@ -1367,6 +1385,7 @@ 'original_name': 'Grid imported', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_energy_imported', 'unique_id': '123456-grid_energy_imported', @@ -1441,6 +1460,7 @@ 'original_name': 'Grid power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_power', 'unique_id': '123456-grid_power', @@ -1515,6 +1535,7 @@ 'original_name': 'Grid services exported', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_energy_exported', 'unique_id': '123456-grid_services_energy_exported', @@ -1589,6 +1610,7 @@ 'original_name': 'Grid services imported', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_energy_imported', 'unique_id': '123456-grid_services_energy_imported', @@ -1663,6 +1685,7 @@ 'original_name': 'Grid services power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_power', 'unique_id': '123456-grid_services_power', @@ -1737,6 +1760,7 @@ 'original_name': 'Home usage', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_home_usage', 'unique_id': '123456-total_home_usage', @@ -1811,6 +1835,7 @@ 'original_name': 'Island status', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'island_status', 'unique_id': '123456-island_status', @@ -1895,6 +1920,7 @@ 'original_name': 'Load power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'load_power', 'unique_id': '123456-load_power', @@ -1966,6 +1992,7 @@ 'original_name': 'Percentage charged', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'percentage_charged', 'unique_id': '123456-percentage_charged', @@ -2040,6 +2067,7 @@ 'original_name': 'Solar exported', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_energy_exported', 'unique_id': '123456-solar_energy_exported', @@ -2114,6 +2142,7 @@ 'original_name': 'Solar generated', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_solar_generation', 'unique_id': '123456-total_solar_generation', @@ -2188,6 +2217,7 @@ 'original_name': 'Solar power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_power', 'unique_id': '123456-solar_power', @@ -2262,6 +2292,7 @@ 'original_name': 'Total pack energy', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_pack_energy', 'unique_id': '123456-total_pack_energy', @@ -2328,6 +2359,7 @@ 'original_name': 'Version', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'version', 'unique_id': '123456-version', @@ -2388,6 +2420,7 @@ 'original_name': 'VPP backup reserve', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vpp_backup_reserve_percent', 'unique_id': '123456-vpp_backup_reserve_percent', @@ -2424,6 +2457,76 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.teslemetry_credits-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.teslemetry_credits', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Teslemetry credits', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'credit_balance', + 'unique_id': 'abc-123_credit_balance', + 'unit_of_measurement': 'credits', + }) +# --- +# name: test_sensors[sensor.teslemetry_credits-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Teslemetry credits', + 'state_class': , + 'unit_of_measurement': 'credits', + }), + 'context': , + 'entity_id': 'sensor.teslemetry_credits', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.teslemetry_credits-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Teslemetry credits', + 'state_class': , + 'unit_of_measurement': 'credits', + }), + 'context': , + 'entity_id': 'sensor.teslemetry_credits', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.test_battery_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2457,6 +2560,7 @@ 'original_name': 'Battery level', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_level', 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_level', @@ -2531,6 +2635,7 @@ 'original_name': 'Battery range', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_range', 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_range', @@ -2597,6 +2702,7 @@ 'original_name': 'Charge cable', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', 'unique_id': 'LRW3F7EK4NC700000-charge_state_conn_charge_cable', @@ -2662,6 +2768,7 @@ 'original_name': 'Charge energy added', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_energy_added', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_energy_added', @@ -2724,6 +2831,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -2733,6 +2843,7 @@ 'original_name': 'Charge rate', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_rate', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_rate', @@ -2752,7 +2863,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_charge_rate-statealt] @@ -2768,7 +2879,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_charger_current-entry] @@ -2795,12 +2906,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charger current', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_actual_current', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_actual_current', @@ -2863,12 +2978,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charger power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_power', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_power', @@ -2931,12 +3050,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charger voltage', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_voltage', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_voltage', @@ -3012,6 +3135,7 @@ 'original_name': 'Charging', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRW3F7EK4NC700000-charge_state_charging_state', @@ -3086,6 +3210,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3095,6 +3222,7 @@ 'original_name': 'Distance to arrival', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_miles_to_arrival', 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_miles_to_arrival', @@ -3114,7 +3242,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.063555', + 'state': '0.063554603904', }) # --- # name: test_sensors[sensor.test_distance_to_arrival-statealt] @@ -3130,7 +3258,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_driver_temperature_setting-entry] @@ -3166,6 +3294,7 @@ 'original_name': 'Driver temperature setting', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_driver_temp_setting', 'unique_id': 'LRW3F7EK4NC700000-climate_state_driver_temp_setting', @@ -3240,6 +3369,7 @@ 'original_name': 'Estimate battery range', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_est_battery_range', 'unique_id': 'LRW3F7EK4NC700000-charge_state_est_battery_range', @@ -3306,6 +3436,7 @@ 'original_name': 'Fast charger type', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_fast_charger_type', 'unique_id': 'LRW3F7EK4NC700000-charge_state_fast_charger_type', @@ -3374,6 +3505,7 @@ 'original_name': 'Ideal battery range', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_ideal_battery_range', 'unique_id': 'LRW3F7EK4NC700000-charge_state_ideal_battery_range', @@ -3445,6 +3577,7 @@ 'original_name': 'Inside temperature', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_inside_temp', 'unique_id': 'LRW3F7EK4NC700000-climate_state_inside_temp', @@ -3519,6 +3652,7 @@ 'original_name': 'Odometer', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_odometer', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_odometer', @@ -3590,6 +3724,7 @@ 'original_name': 'Outside temperature', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_outside_temp', 'unique_id': 'LRW3F7EK4NC700000-climate_state_outside_temp', @@ -3661,6 +3796,7 @@ 'original_name': 'Passenger temperature setting', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_passenger_temp_setting', 'unique_id': 'LRW3F7EK4NC700000-climate_state_passenger_temp_setting', @@ -3723,12 +3859,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_power', 'unique_id': 'LRW3F7EK4NC700000-drive_state_power', @@ -3802,6 +3942,7 @@ 'original_name': 'Shift state', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_shift_state', 'unique_id': 'LRW3F7EK4NC700000-drive_state_shift_state', @@ -3872,6 +4013,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3881,6 +4025,7 @@ 'original_name': 'Speed', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_speed', 'unique_id': 'LRW3F7EK4NC700000-drive_state_speed', @@ -3949,6 +4094,7 @@ 'original_name': 'State of charge at arrival', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_energy_at_arrival', 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_energy_at_arrival', @@ -4015,6 +4161,7 @@ 'original_name': 'Time to arrival', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_minutes_to_arrival', 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_minutes_to_arrival', @@ -4077,6 +4224,7 @@ 'original_name': 'Time to full charge', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_minutes_to_full_charge', 'unique_id': 'LRW3F7EK4NC700000-charge_state_minutes_to_full_charge', @@ -4147,6 +4295,7 @@ 'original_name': 'Tire pressure front left', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fl', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_fl', @@ -4221,6 +4370,7 @@ 'original_name': 'Tire pressure front right', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fr', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_fr', @@ -4295,6 +4445,7 @@ 'original_name': 'Tire pressure rear left', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rl', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_rl', @@ -4369,6 +4520,7 @@ 'original_name': 'Tire pressure rear right', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rr', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_rr', @@ -4431,12 +4583,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Traffic delay', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_traffic_minutes_delay', 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_traffic_minutes_delay', @@ -4508,6 +4664,7 @@ 'original_name': 'Usable battery level', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_usable_battery_level', 'unique_id': 'LRW3F7EK4NC700000-charge_state_usable_battery_level', @@ -4574,6 +4731,7 @@ 'original_name': 'Fault state code', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_fault_state', 'unique_id': '123456-abd-123-wall_connector_fault_state', @@ -4634,6 +4792,7 @@ 'original_name': 'Fault state code', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_fault_state', 'unique_id': '123456-bcd-234-wall_connector_fault_state', @@ -4702,6 +4861,7 @@ 'original_name': 'Power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_power', 'unique_id': '123456-abd-123-wall_connector_power', @@ -4776,6 +4936,7 @@ 'original_name': 'Power', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_power', 'unique_id': '123456-bcd-234-wall_connector_power', @@ -4842,6 +5003,7 @@ 'original_name': 'State code', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_state', 'unique_id': '123456-abd-123-wall_connector_state', @@ -4902,6 +5064,7 @@ 'original_name': 'State code', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_state', 'unique_id': '123456-bcd-234-wall_connector_state', @@ -4962,6 +5125,7 @@ 'original_name': 'Vehicle', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vin', 'unique_id': '123456-abd-123-vin', @@ -5022,6 +5186,7 @@ 'original_name': 'Vehicle', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vin', 'unique_id': '123456-bcd-234-vin', @@ -5054,6 +5219,9 @@ 'state': 'disconnected', }) # --- +# name: test_sensors_streaming[sensor.teslemetry_credits-state] + '1980' +# --- # name: test_sensors_streaming[sensor.test_battery_level-state] '90' # --- diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index 0586b454a91..bbcadd25a48 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Allow charging from grid', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'components_disallow_charge_from_grid_with_solar_installed', 'unique_id': '123456-components_disallow_charge_from_grid_with_solar_installed', @@ -75,6 +76,7 @@ 'original_name': 'Storm watch', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'user_settings_storm_mode_enabled', 'unique_id': '123456-user_settings_storm_mode_enabled', @@ -123,6 +125,7 @@ 'original_name': 'Auto seat climate left', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_left', 'unique_id': 'LRW3F7EK4NC700000-climate_state_auto_seat_climate_left', @@ -171,6 +174,7 @@ 'original_name': 'Auto seat climate right', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_right', 'unique_id': 'LRW3F7EK4NC700000-climate_state_auto_seat_climate_right', @@ -219,6 +223,7 @@ 'original_name': 'Auto steering wheel heater', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_steering_wheel_heat', 'unique_id': 'LRW3F7EK4NC700000-climate_state_auto_steering_wheel_heat', @@ -267,6 +272,7 @@ 'original_name': 'Charge', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRW3F7EK4NC700000-charge_state_user_charge_enable_request', @@ -315,6 +321,7 @@ 'original_name': 'Defrost', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_defrost_mode', 'unique_id': 'LRW3F7EK4NC700000-climate_state_defrost_mode', @@ -363,6 +370,7 @@ 'original_name': 'Sentry mode', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_sentry_mode', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sentry_mode', @@ -383,6 +391,55 @@ 'state': 'off', }) # --- +# name: test_switch[switch.test_valet_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.test_valet_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valet mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_valet_mode', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_valet_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_valet_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Valet mode', + }), + 'context': , + 'entity_id': 'switch.test_valet_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_alt[switch.energy_site_allow_charging_from_grid-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -495,6 +552,20 @@ 'state': 'off', }) # --- +# name: test_switch_alt[switch.test_valet_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Valet mode', + }), + 'context': , + 'entity_id': 'switch.test_valet_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_streaming[switch.test_auto_seat_climate_left] 'on' # --- diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 391d81c086e..6f939c667b2 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': 'Update', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_software_update_status', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_software_update_status', @@ -86,6 +87,7 @@ 'original_name': 'Update', 'platform': 'teslemetry', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_software_update_status', 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_software_update_status', diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index 213811f6ea0..f50dc93bde4 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -73,6 +73,12 @@ async def test_sensors_streaming( Signal.TIME_TO_FULL_CHARGE: 0.166666667, Signal.MINUTES_TO_ARRIVAL: None, }, + "credits": { + "type": "wake_up", + "cost": 20, + "name": "wake_up", + "balance": 1980, + }, "createdAt": "2024-10-04T10:45:17.537Z", } ) @@ -91,6 +97,7 @@ async def test_sensors_streaming( "sensor.test_charge_cable", "sensor.test_time_to_full_charge", "sensor.test_time_to_arrival", + "sensor.teslemetry_credits", ): state = hass.states.get(entity_id) assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index 37a38fffaa4..a78d91e3f48 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aiohttp import ClientConnectionError, ClientResponseError from aiohttp.client import RequestInfo -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tessie import PLATFORMS from homeassistant.components.tessie.const import DOMAIN, TessieStatus diff --git a/tests/components/tessie/snapshots/test_binary_sensor.ambr b/tests/components/tessie/snapshots/test_binary_sensor.ambr index 2fe97b88811..e1875626f76 100644 --- a/tests/components/tessie/snapshots/test_binary_sensor.ambr +++ b/tests/components/tessie/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Backup capable', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backup_capable', 'unique_id': '123456-backup_capable', @@ -74,6 +75,7 @@ 'original_name': 'Grid services active', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_active', 'unique_id': '123456-grid_services_active', @@ -121,6 +123,7 @@ 'original_name': 'Grid services enabled', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'components_grid_services_enabled', 'unique_id': '123456-components_grid_services_enabled', @@ -168,6 +171,7 @@ 'original_name': 'Storm watch active', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'storm_mode_active', 'unique_id': '123456-storm_mode_active', @@ -215,6 +219,7 @@ 'original_name': 'Auto seat climate left', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_left', 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_left', @@ -262,6 +267,7 @@ 'original_name': 'Auto seat climate right', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_right', 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_right', @@ -309,6 +315,7 @@ 'original_name': 'Auto steering wheel heater', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_steering_wheel_heat', 'unique_id': 'VINVINVIN-climate_state_auto_steering_wheel_heat', @@ -356,6 +363,7 @@ 'original_name': 'Battery heater', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_battery_heater', 'unique_id': 'VINVINVIN-climate_state_battery_heater', @@ -404,6 +412,7 @@ 'original_name': 'Cabin overheat protection', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_cabin_overheat_protection', 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection', @@ -452,6 +461,7 @@ 'original_name': 'Cabin overheat protection actively cooling', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection_actively_cooling', @@ -500,6 +510,7 @@ 'original_name': 'Charge cable', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', 'unique_id': 'VINVINVIN-charge_state_conn_charge_cable', @@ -548,6 +559,7 @@ 'original_name': 'Charging', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', 'unique_id': 'VINVINVIN-charge_state_charging_state', @@ -596,6 +608,7 @@ 'original_name': 'Dashcam', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dashcam_state', 'unique_id': 'VINVINVIN-vehicle_state_dashcam_state', @@ -644,6 +657,7 @@ 'original_name': 'Front driver door', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_df', 'unique_id': 'VINVINVIN-vehicle_state_df', @@ -692,6 +706,7 @@ 'original_name': 'Front driver window', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fd_window', 'unique_id': 'VINVINVIN-vehicle_state_fd_window', @@ -740,6 +755,7 @@ 'original_name': 'Front passenger door', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pf', 'unique_id': 'VINVINVIN-vehicle_state_pf', @@ -788,6 +804,7 @@ 'original_name': 'Front passenger window', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fp_window', 'unique_id': 'VINVINVIN-vehicle_state_fp_window', @@ -836,6 +853,7 @@ 'original_name': 'Preconditioning enabled', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_preconditioning_enabled', 'unique_id': 'VINVINVIN-charge_state_preconditioning_enabled', @@ -883,6 +901,7 @@ 'original_name': 'Rear driver door', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dr', 'unique_id': 'VINVINVIN-vehicle_state_dr', @@ -931,6 +950,7 @@ 'original_name': 'Rear driver window', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rd_window', 'unique_id': 'VINVINVIN-vehicle_state_rd_window', @@ -979,6 +999,7 @@ 'original_name': 'Rear passenger door', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pr', 'unique_id': 'VINVINVIN-vehicle_state_pr', @@ -1027,6 +1048,7 @@ 'original_name': 'Rear passenger window', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rp_window', 'unique_id': 'VINVINVIN-vehicle_state_rp_window', @@ -1075,6 +1097,7 @@ 'original_name': 'Scheduled charging pending', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_scheduled_charging_pending', 'unique_id': 'VINVINVIN-charge_state_scheduled_charging_pending', @@ -1122,6 +1145,7 @@ 'original_name': 'Status', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state', 'unique_id': 'VINVINVIN-state', @@ -1170,6 +1194,7 @@ 'original_name': 'Tire pressure warning front left', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fl', 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fl', @@ -1218,6 +1243,7 @@ 'original_name': 'Tire pressure warning front right', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fr', 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fr', @@ -1266,6 +1292,7 @@ 'original_name': 'Tire pressure warning rear left', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rl', 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rl', @@ -1314,6 +1341,7 @@ 'original_name': 'Tire pressure warning rear right', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rr', 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rr', @@ -1362,6 +1390,7 @@ 'original_name': 'Trip charging', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_trip_charging', 'unique_id': 'VINVINVIN-charge_state_trip_charging', @@ -1409,6 +1438,7 @@ 'original_name': 'User present', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_is_user_present', 'unique_id': 'VINVINVIN-vehicle_state_is_user_present', diff --git a/tests/components/tessie/snapshots/test_button.ambr b/tests/components/tessie/snapshots/test_button.ambr index 96ece94a1c9..fda5fe9a59f 100644 --- a/tests/components/tessie/snapshots/test_button.ambr +++ b/tests/components/tessie/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Flash lights', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flash_lights', 'unique_id': 'VINVINVIN-flash_lights', @@ -74,6 +75,7 @@ 'original_name': 'Homelink', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'trigger_homelink', 'unique_id': 'VINVINVIN-trigger_homelink', @@ -121,6 +123,7 @@ 'original_name': 'Honk horn', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'honk', 'unique_id': 'VINVINVIN-honk', @@ -168,6 +171,7 @@ 'original_name': 'Keyless driving', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'enable_keyless_driving', 'unique_id': 'VINVINVIN-enable_keyless_driving', @@ -215,6 +219,7 @@ 'original_name': 'Play fart', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boombox', 'unique_id': 'VINVINVIN-boombox', @@ -262,6 +267,7 @@ 'original_name': 'Wake', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wake', 'unique_id': 'VINVINVIN-wake', diff --git a/tests/components/tessie/snapshots/test_climate.ambr b/tests/components/tessie/snapshots/test_climate.ambr index 415988e783e..50756cef338 100644 --- a/tests/components/tessie/snapshots/test_climate.ambr +++ b/tests/components/tessie/snapshots/test_climate.ambr @@ -40,6 +40,7 @@ 'original_name': 'Climate', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'primary', 'unique_id': 'VINVINVIN-primary', diff --git a/tests/components/tessie/snapshots/test_cover.ambr b/tests/components/tessie/snapshots/test_cover.ambr index fdf2a967048..bcb2a13dbef 100644 --- a/tests/components/tessie/snapshots/test_cover.ambr +++ b/tests/components/tessie/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge port door', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'charge_state_charge_port_door_open', 'unique_id': 'VINVINVIN-charge_state_charge_port_door_open', @@ -76,6 +77,7 @@ 'original_name': 'Frunk', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_ft', 'unique_id': 'VINVINVIN-vehicle_state_ft', @@ -125,6 +127,7 @@ 'original_name': 'Sunroof', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_sun_roof_state', 'unique_id': 'VINVINVIN-vehicle_state_sun_roof_state', @@ -174,6 +177,7 @@ 'original_name': 'Trunk', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vehicle_state_rt', 'unique_id': 'VINVINVIN-vehicle_state_rt', @@ -223,6 +227,7 @@ 'original_name': 'Vent windows', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'windows', 'unique_id': 'VINVINVIN-windows', diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr index 92502340aa2..5887d1abd2b 100644 --- a/tests/components/tessie/snapshots/test_device_tracker.ambr +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': 'Location', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'VINVINVIN-location', @@ -80,6 +81,7 @@ 'original_name': 'Route', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'route', 'unique_id': 'VINVINVIN-route', diff --git a/tests/components/tessie/snapshots/test_lock.ambr b/tests/components/tessie/snapshots/test_lock.ambr index f819281d79b..57cbcd4434f 100644 --- a/tests/components/tessie/snapshots/test_lock.ambr +++ b/tests/components/tessie/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': 'Charge cable lock', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_latch', 'unique_id': 'VINVINVIN-charge_state_charge_port_latch', @@ -75,6 +76,7 @@ 'original_name': 'Lock', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_locked', 'unique_id': 'VINVINVIN-vehicle_state_locked', diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index 911598004a6..69a5ca4b86b 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -28,6 +28,7 @@ 'original_name': 'Media player', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'media', 'unique_id': 'VINVINVIN-media', @@ -40,7 +41,7 @@ 'device_class': 'speaker', 'friendly_name': 'Test Media player', 'supported_features': , - 'volume_level': 0.22580323309042688, + 'volume_level': 0.2258032258064516, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -63,7 +64,7 @@ 'media_title': 'Song', 'source': 'Spotify', 'supported_features': , - 'volume_level': 0.22580323309042688, + 'volume_level': 0.2258032258064516, }), 'context': , 'entity_id': 'media_player.test_media_player', diff --git a/tests/components/tessie/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr index e865058c4a2..dd81c439e0c 100644 --- a/tests/components/tessie/snapshots/test_number.ambr +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Backup reserve', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backup_reserve_percent', 'unique_id': '123456-backup_reserve_percent', @@ -91,6 +92,7 @@ 'original_name': 'Off-grid reserve', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'off_grid_vehicle_charging_reserve_percent', 'unique_id': '123456-off_grid_vehicle_charging_reserve_percent', @@ -150,6 +152,7 @@ 'original_name': 'Charge current', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_current_request', 'unique_id': 'VINVINVIN-charge_state_charge_current_request', @@ -208,6 +211,7 @@ 'original_name': 'Charge limit', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_limit_soc', 'unique_id': 'VINVINVIN-charge_state_charge_limit_soc', @@ -266,6 +270,7 @@ 'original_name': 'Speed limit', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_speed_limit_mode_current_limit_mph', 'unique_id': 'VINVINVIN-vehicle_state_speed_limit_mode_current_limit_mph', diff --git a/tests/components/tessie/snapshots/test_select.ambr b/tests/components/tessie/snapshots/test_select.ambr index f118633aded..6a08b7b2b91 100644 --- a/tests/components/tessie/snapshots/test_select.ambr +++ b/tests/components/tessie/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Allow export', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'components_customer_preferred_export_rule', 'unique_id': '123456-components_customer_preferred_export_rule', @@ -91,6 +92,7 @@ 'original_name': 'Operation mode', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'default_real_mode', 'unique_id': '123456-default_real_mode', @@ -150,6 +152,7 @@ 'original_name': 'Seat cooler left', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_fan_front_left', 'unique_id': 'VINVINVIN-climate_state_seat_fan_front_left', @@ -210,6 +213,7 @@ 'original_name': 'Seat cooler right', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_fan_front_right', 'unique_id': 'VINVINVIN-climate_state_seat_fan_front_right', @@ -270,6 +274,7 @@ 'original_name': 'Seat heater left', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_left', 'unique_id': 'VINVINVIN-climate_state_seat_heater_left', @@ -330,6 +335,7 @@ 'original_name': 'Seat heater rear center', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_center', 'unique_id': 'VINVINVIN-climate_state_seat_heater_rear_center', @@ -390,6 +396,7 @@ 'original_name': 'Seat heater rear left', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_left', 'unique_id': 'VINVINVIN-climate_state_seat_heater_rear_left', @@ -450,6 +457,7 @@ 'original_name': 'Seat heater rear right', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_right', 'unique_id': 'VINVINVIN-climate_state_seat_heater_rear_right', @@ -510,6 +518,7 @@ 'original_name': 'Seat heater right', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_right', 'unique_id': 'VINVINVIN-climate_state_seat_heater_right', diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index b40cf204bca..ca2a379c5f2 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -35,6 +35,7 @@ 'original_name': 'Battery power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_power', 'unique_id': '123456-battery_power', @@ -93,6 +94,7 @@ 'original_name': 'Energy left', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_left', 'unique_id': '123456-energy_left', @@ -151,6 +153,7 @@ 'original_name': 'Generator power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'generator_power', 'unique_id': '123456-generator_power', @@ -209,6 +212,7 @@ 'original_name': 'Grid power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_power', 'unique_id': '123456-grid_power', @@ -267,6 +271,7 @@ 'original_name': 'Grid services power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'grid_services_power', 'unique_id': '123456-grid_services_power', @@ -325,6 +330,7 @@ 'original_name': 'Load power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'load_power', 'unique_id': '123456-load_power', @@ -380,6 +386,7 @@ 'original_name': 'Percentage charged', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'percentage_charged', 'unique_id': '123456-percentage_charged', @@ -438,6 +445,7 @@ 'original_name': 'Solar power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'solar_power', 'unique_id': '123456-solar_power', @@ -496,6 +504,7 @@ 'original_name': 'Total pack energy', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_pack_energy', 'unique_id': '123456-total_pack_energy', @@ -546,6 +555,7 @@ 'original_name': 'VPP backup reserve', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vpp_backup_reserve_percent', 'unique_id': '123456-vpp_backup_reserve_percent', @@ -597,6 +607,7 @@ 'original_name': 'Battery level', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_usable_battery_level', 'unique_id': 'VINVINVIN-charge_state_usable_battery_level', @@ -655,6 +666,7 @@ 'original_name': 'Battery range', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_range', 'unique_id': 'VINVINVIN-charge_state_battery_range', @@ -713,6 +725,7 @@ 'original_name': 'Battery range estimate', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_est_battery_range', 'unique_id': 'VINVINVIN-charge_state_est_battery_range', @@ -771,6 +784,7 @@ 'original_name': 'Battery range ideal', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_ideal_battery_range', 'unique_id': 'VINVINVIN-charge_state_ideal_battery_range', @@ -826,6 +840,7 @@ 'original_name': 'Charge energy added', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_energy_added', 'unique_id': 'VINVINVIN-charge_state_charge_energy_added', @@ -872,6 +887,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -881,6 +899,7 @@ 'original_name': 'Charge rate', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_rate', 'unique_id': 'VINVINVIN-charge_state_charge_rate', @@ -900,7 +919,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '49.2', + 'state': '49.2459264', }) # --- # name: test_sensors[sensor.test_charger_current-entry] @@ -927,12 +946,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charger current', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_actual_current', 'unique_id': 'VINVINVIN-charge_state_charger_actual_current', @@ -979,12 +1002,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charger power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_power', 'unique_id': 'VINVINVIN-charge_state_charger_power', @@ -1031,12 +1058,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charger voltage', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_voltage', 'unique_id': 'VINVINVIN-charge_state_charger_voltage', @@ -1096,6 +1127,7 @@ 'original_name': 'Charging', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', 'unique_id': 'VINVINVIN-charge_state_charging_state', @@ -1152,6 +1184,7 @@ 'original_name': 'Destination', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_destination', 'unique_id': 'VINVINVIN-drive_state_active_route_destination', @@ -1195,6 +1228,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1204,6 +1240,7 @@ 'original_name': 'Distance to arrival', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_miles_to_arrival', 'unique_id': 'VINVINVIN-drive_state_active_route_miles_to_arrival', @@ -1223,7 +1260,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '75.168198', + 'state': '75.168198306432', }) # --- # name: test_sensors[sensor.test_driver_temperature_setting-entry] @@ -1259,6 +1296,7 @@ 'original_name': 'Driver temperature setting', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_driver_temp_setting', 'unique_id': 'VINVINVIN-climate_state_driver_temp_setting', @@ -1314,6 +1352,7 @@ 'original_name': 'Inside temperature', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_inside_temp', 'unique_id': 'VINVINVIN-climate_state_inside_temp', @@ -1372,6 +1411,7 @@ 'original_name': 'Odometer', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_odometer', 'unique_id': 'VINVINVIN-vehicle_state_odometer', @@ -1427,6 +1467,7 @@ 'original_name': 'Outside temperature', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_outside_temp', 'unique_id': 'VINVINVIN-climate_state_outside_temp', @@ -1482,6 +1523,7 @@ 'original_name': 'Passenger temperature setting', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_passenger_temp_setting', 'unique_id': 'VINVINVIN-climate_state_passenger_temp_setting', @@ -1528,12 +1570,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_power', 'unique_id': 'VINVINVIN-drive_state_power', @@ -1591,6 +1637,7 @@ 'original_name': 'Shift state', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_shift_state', 'unique_id': 'VINVINVIN-drive_state_shift_state', @@ -1641,6 +1688,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1650,6 +1700,7 @@ 'original_name': 'Speed', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_speed', 'unique_id': 'VINVINVIN-drive_state_speed', @@ -1702,6 +1753,7 @@ 'original_name': 'State of charge at arrival', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_energy_at_arrival', 'unique_id': 'VINVINVIN-drive_state_active_route_energy_at_arrival', @@ -1752,6 +1804,7 @@ 'original_name': 'Time to arrival', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_minutes_to_arrival', 'unique_id': 'VINVINVIN-drive_state_active_route_minutes_to_arrival', @@ -1800,6 +1853,7 @@ 'original_name': 'Time to full charge', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_minutes_to_full_charge', 'unique_id': 'VINVINVIN-charge_state_minutes_to_full_charge', @@ -1856,6 +1910,7 @@ 'original_name': 'Tire pressure front left', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fl', 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_fl', @@ -1914,6 +1969,7 @@ 'original_name': 'Tire pressure front right', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fr', 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_fr', @@ -1972,6 +2028,7 @@ 'original_name': 'Tire pressure rear left', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rl', 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_rl', @@ -2030,6 +2087,7 @@ 'original_name': 'Tire pressure rear right', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rr', 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_rr', @@ -2076,12 +2134,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Traffic delay', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_traffic_minutes_delay', 'unique_id': 'VINVINVIN-drive_state_active_route_traffic_minutes_delay', @@ -2140,6 +2202,7 @@ 'original_name': 'Power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_power', 'unique_id': '123456-abd-123-wall_connector_power', @@ -2198,6 +2261,7 @@ 'original_name': 'Power', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_power', 'unique_id': '123456-bcd-234-wall_connector_power', @@ -2261,6 +2325,7 @@ 'original_name': 'State', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_state', 'unique_id': '123456-abd-123-wall_connector_state', @@ -2334,6 +2399,7 @@ 'original_name': 'State', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wall_connector_state', 'unique_id': '123456-bcd-234-wall_connector_state', @@ -2394,6 +2460,7 @@ 'original_name': 'Vehicle', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vin', 'unique_id': '123456-abd-123-vin', @@ -2441,6 +2508,7 @@ 'original_name': 'Vehicle', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vin', 'unique_id': '123456-bcd-234-vin', diff --git a/tests/components/tessie/snapshots/test_switch.ambr b/tests/components/tessie/snapshots/test_switch.ambr index 371ef822122..e0a59cd967b 100644 --- a/tests/components/tessie/snapshots/test_switch.ambr +++ b/tests/components/tessie/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Allow charging from grid', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'components_disallow_charge_from_grid_with_solar_installed', 'unique_id': '123456-components_disallow_charge_from_grid_with_solar_installed', @@ -74,6 +75,7 @@ 'original_name': 'Storm watch', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'user_settings_storm_mode_enabled', 'unique_id': '123456-user_settings_storm_mode_enabled', @@ -121,6 +123,7 @@ 'original_name': 'Charge', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', 'unique_id': 'VINVINVIN-charge_state_charge_enable_request', @@ -169,6 +172,7 @@ 'original_name': 'Defrost mode', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_defrost_mode', 'unique_id': 'VINVINVIN-climate_state_defrost_mode', @@ -217,6 +221,7 @@ 'original_name': 'Sentry mode', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_sentry_mode', 'unique_id': 'VINVINVIN-vehicle_state_sentry_mode', @@ -265,6 +270,7 @@ 'original_name': 'Steering wheel heater', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'climate_state_steering_wheel_heater', 'unique_id': 'VINVINVIN-climate_state_steering_wheel_heater', @@ -313,6 +319,7 @@ 'original_name': 'Valet mode', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_valet_mode', 'unique_id': 'VINVINVIN-vehicle_state_valet_mode', diff --git a/tests/components/tessie/snapshots/test_update.ambr b/tests/components/tessie/snapshots/test_update.ambr index e4c25e2230f..8780f64bb09 100644 --- a/tests/components/tessie/snapshots/test_update.ambr +++ b/tests/components/tessie/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': 'Update', 'platform': 'tessie', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'update', 'unique_id': 'VINVINVIN-update', diff --git a/tests/components/tessie/test_binary_sensor.py b/tests/components/tessie/test_binary_sensor.py index 0ced8a6d8aa..26d343181fa 100644 --- a/tests/components/tessie/test_binary_sensor.py +++ b/tests/components/tessie/test_binary_sensor.py @@ -1,7 +1,7 @@ """Test the Tessie binary sensor platform.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index c9cfca3288a..da5942c0fdd 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py index bc688e1ca70..4a0134c1b58 100644 --- a/tests/components/tessie/test_climate.py +++ b/tests/components/tessie/test_climate.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_HVAC_MODE, diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 02a8f22b6ea..b71b1f44377 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py index 08d96b7303e..01defd8844c 100644 --- a/tests/components/tessie/test_device_tracker.py +++ b/tests/components/tessie/test_device_tracker.py @@ -1,6 +1,6 @@ """Test the Tessie device tracker platform.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py index 1208bb17d55..f94614bd2bf 100644 --- a/tests/components/tessie/test_lock.py +++ b/tests/components/tessie/test_lock.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, diff --git a/tests/components/tessie/test_media_player.py b/tests/components/tessie/test_media_player.py index 008607b8018..27a4828b6bb 100644 --- a/tests/components/tessie/test_media_player.py +++ b/tests/components/tessie/test_media_player.py @@ -3,7 +3,7 @@ from datetime import timedelta from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL from homeassistant.const import Platform diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py index 69bbe1c9087..8f1d0820ea9 100644 --- a/tests/components/tessie/test_number.py +++ b/tests/components/tessie/test_number.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py index 64380d363fc..44a5e99b5c1 100644 --- a/tests/components/tessie/test_select.py +++ b/tests/components/tessie/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode from tesla_fleet_api.exceptions import UnsupportedVehicle diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py index 92256d25eb1..144ec06723d 100644 --- a/tests/components/tessie/test_sensor.py +++ b/tests/components/tessie/test_sensor.py @@ -2,7 +2,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index f58468edfb7..aaa9c769ff8 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py index 8d098e9a966..3510632b62c 100644 --- a/tests/components/tessie/test_update.py +++ b/tests/components/tessie/test_update.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.update import ( ATTR_IN_PROGRESS, diff --git a/tests/components/threshold/test_config_flow.py b/tests/components/threshold/test_config_flow.py index 5d9d22c3f81..3c27f09d396 100644 --- a/tests/components/threshold/test_config_flow.py +++ b/tests/components/threshold/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.threshold.const import DOMAIN diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 6e85d659922..599612ce0b7 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -1,14 +1,95 @@ """Test the Min/Max integration.""" +from unittest.mock import patch + import pytest +from homeassistant.components import threshold +from homeassistant.components.threshold.config_flow import ConfigFlowHandler from homeassistant.components.threshold.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def threshold_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a threshold config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": sensor_entity_entry.entity_id, + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + @pytest.mark.parametrize("platform", ["binary_sensor"]) async def test_setup_and_remove_config_entry( hass: HomeAssistant, @@ -208,3 +289,194 @@ async def test_device_cleaning( threshold_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the threshold config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the threshold config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the threshold config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert threshold_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the threshold config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert threshold_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the threshold config entry is updated with the new entity ID + assert threshold_config_entry.options["entity_id"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] diff --git a/tests/components/tile/snapshots/test_binary_sensor.ambr b/tests/components/tile/snapshots/test_binary_sensor.ambr index 6de356ebf51..1a8cbdbff36 100644 --- a/tests/components/tile/snapshots/test_binary_sensor.ambr +++ b/tests/components/tile/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Lost', 'platform': 'tile', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lost', 'unique_id': 'user@host.com_19264d2dffdbca32_lost', diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr index 3f94f679f10..069d66a42e6 100644 --- a/tests/components/tile/snapshots/test_device_tracker.ambr +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'tile', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tile', 'unique_id': 'user@host.com_19264d2dffdbca32', diff --git a/tests/components/tile/test_binary_sensor.py b/tests/components/tile/test_binary_sensor.py index c8b4b9b8376..e5606baf5c7 100644 --- a/tests/components/tile/test_binary_sensor.py +++ b/tests/components/tile/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tile/test_device_tracker.py b/tests/components/tile/test_device_tracker.py index 105cae1a7d7..50718114aa6 100644 --- a/tests/components/tile/test_device_tracker.py +++ b/tests/components/tile/test_device_tracker.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tile/test_diagnostics.py b/tests/components/tile/test_diagnostics.py index 87bc670d604..0c7e0001ff3 100644 --- a/tests/components/tile/test_diagnostics.py +++ b/tests/components/tile/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/tile/test_init.py b/tests/components/tile/test_init.py index fba354ade17..28daac6ff5d 100644 --- a/tests/components/tile/test_init.py +++ b/tests/components/tile/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tile.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/tilt_ble/test_sensor.py b/tests/components/tilt_ble/test_sensor.py index 207e49a22cd..ded46de4ffe 100644 --- a/tests/components/tilt_ble/test_sensor.py +++ b/tests/components/tilt_ble/test_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.components.sensor import ATTR_STATE_CLASS, async_rounded_state from homeassistant.components.tilt_ble.const import DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -35,7 +35,10 @@ async def test_sensors(hass: HomeAssistant) -> None: assert temp_sensor is not None temp_sensor_attribtes = temp_sensor.attributes - assert temp_sensor.state == "21" + assert ( + async_rounded_state(hass, "sensor.tilt_green_temperature", temp_sensor) + == "21.1" + ) assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Tilt Green Temperature" assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C" assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 43b0e33aed4..31cdca62635 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -8,7 +8,7 @@ from typing import Any from freezegun import freeze_time import pytest -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, async_rounded_state from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, _get_unique_id, @@ -142,9 +142,10 @@ async def _setup( def check_sensor_state(hass: HomeAssistant, entity_name: str, value: str): """Check the state of a Tomorrow.io sensor.""" - state = hass.states.get(CC_SENSOR_ENTITY_ID.format(entity_name)) + entity_id = CC_SENSOR_ENTITY_ID.format(entity_name) + state = hass.states.get(entity_id) assert state - assert state.state == value + assert async_rounded_state(hass, entity_id, state) == value assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION @@ -168,7 +169,7 @@ async def test_v4_sensor(hass: HomeAssistant) -> None: check_sensor_state(hass, WEED_POLLEN, "none") check_sensor_state(hass, TREE_POLLEN, "none") check_sensor_state(hass, FEELS_LIKE, "101.3") - check_sensor_state(hass, DEW_POINT, "72.82") + check_sensor_state(hass, DEW_POINT, "72.8") check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "29.47") check_sensor_state(hass, GHI, "0") check_sensor_state(hass, CLOUD_BASE, "0.74") @@ -201,8 +202,8 @@ async def test_v4_sensor_imperial(hass: HomeAssistant) -> None: check_sensor_state(hass, WEED_POLLEN, "none") check_sensor_state(hass, TREE_POLLEN, "none") check_sensor_state(hass, FEELS_LIKE, "214.3") - check_sensor_state(hass, DEW_POINT, "163.08") - check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "0.427") + check_sensor_state(hass, DEW_POINT, "163.1") + check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "0.43") check_sensor_state(hass, GHI, "0.0") check_sensor_state(hass, CLOUD_BASE, "0.46") check_sensor_state(hass, CLOUD_COVER, "100") diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index ac32b50762f..174ab96e8dc 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456', @@ -78,6 +79,7 @@ 'original_name': 'Partition 2', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'partition', 'unique_id': '123456_2', diff --git a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr index ac79455a0d5..75aaddf8572 100644 --- a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr +++ b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_2_zone', @@ -78,6 +79,7 @@ 'original_name': 'Battery', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_2_low_battery', @@ -129,6 +131,7 @@ 'original_name': 'Tamper', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_2_tamper', @@ -180,6 +183,7 @@ 'original_name': None, 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_3_zone', @@ -231,6 +235,7 @@ 'original_name': 'Battery', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_3_low_battery', @@ -282,6 +287,7 @@ 'original_name': 'Tamper', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_3_tamper', @@ -333,6 +339,7 @@ 'original_name': None, 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_5_zone', @@ -384,6 +391,7 @@ 'original_name': None, 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_4_zone', @@ -435,6 +443,7 @@ 'original_name': 'Battery', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_4_low_battery', @@ -486,6 +495,7 @@ 'original_name': 'Tamper', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_4_tamper', @@ -537,6 +547,7 @@ 'original_name': None, 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_1_zone', @@ -588,6 +599,7 @@ 'original_name': 'Battery', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_1_low_battery', @@ -639,6 +651,7 @@ 'original_name': 'Tamper', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_1_tamper', @@ -690,6 +703,7 @@ 'original_name': None, 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_7_zone', @@ -741,6 +755,7 @@ 'original_name': 'Battery', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_7_low_battery', @@ -792,6 +807,7 @@ 'original_name': 'Tamper', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_7_tamper', @@ -843,6 +859,7 @@ 'original_name': 'Battery', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_low_battery', @@ -892,6 +909,7 @@ 'original_name': 'Carbon monoxide', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_carbon_monoxide', @@ -941,6 +959,7 @@ 'original_name': 'Police emergency', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'police', 'unique_id': '123456_police', @@ -989,6 +1008,7 @@ 'original_name': 'Power', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_power', @@ -1038,6 +1058,7 @@ 'original_name': 'Smoke', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_smoke', @@ -1087,6 +1108,7 @@ 'original_name': 'Tamper', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_tamper', @@ -1136,6 +1158,7 @@ 'original_name': None, 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_6_zone', @@ -1187,6 +1210,7 @@ 'original_name': 'Battery', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_6_low_battery', @@ -1238,6 +1262,7 @@ 'original_name': 'Tamper', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456_6_tamper', diff --git a/tests/components/totalconnect/snapshots/test_button.ambr b/tests/components/totalconnect/snapshots/test_button.ambr index 96d38567236..4367b035cc8 100644 --- a/tests/components/totalconnect/snapshots/test_button.ambr +++ b/tests/components/totalconnect/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Bypass', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', 'unique_id': '123456_2_bypass', @@ -74,6 +75,7 @@ 'original_name': 'Bypass', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', 'unique_id': '123456_3_bypass', @@ -121,6 +123,7 @@ 'original_name': 'Bypass', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', 'unique_id': '123456_4_bypass', @@ -168,6 +171,7 @@ 'original_name': 'Bypass', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', 'unique_id': '123456_1_bypass', @@ -215,6 +219,7 @@ 'original_name': 'Bypass all', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass_all', 'unique_id': '123456_bypass_all', @@ -262,6 +267,7 @@ 'original_name': 'Clear bypass', 'platform': 'totalconnect', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'clear_bypass', 'unique_id': '123456_clear_bypass', diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index 6ba067b8ae2..6f7d8163362 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from total_connect_client.exceptions import ( AuthenticationError, ServiceUnavailable, diff --git a/tests/components/totalconnect/test_binary_sensor.py b/tests/components/totalconnect/test_binary_sensor.py index dc433129ac8..8910487ea58 100644 --- a/tests/components/totalconnect/test_binary_sensor.py +++ b/tests/components/totalconnect/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR, diff --git a/tests/components/totalconnect/test_button.py b/tests/components/totalconnect/test_button.py index 87764e55186..092b058e693 100644 --- a/tests/components/totalconnect/test_button.py +++ b/tests/components/totalconnect/test_button.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from total_connect_client.exceptions import FailedToBypassZone from homeassistant.components.button import DOMAIN as BUTTON, SERVICE_PRESS diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index ac5bb347765..c67f1495986 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -20,7 +20,7 @@ from kasa.smart.modules import Speaker from kasa.smart.modules.alarm import Alarm from kasa.smart.modules.clean import AreaUnit, Clean, ErrorCode, Status from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.tplink.const import DOMAIN diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index 17aa2c248e5..c8251bccd4f 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_low', 'unique_id': '123456789ABCDEFGH_battery_low', @@ -61,6 +62,7 @@ 'original_name': 'Cloud connection', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', 'unique_id': '123456789ABCDEFGH_cloud_connection', @@ -109,6 +111,7 @@ 'original_name': 'Door', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_open', 'unique_id': '123456789ABCDEFGH_is_open', @@ -157,6 +160,7 @@ 'original_name': 'Humidity warning', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_warning', 'unique_id': '123456789ABCDEFGH_humidity_warning', @@ -191,6 +195,7 @@ 'original_name': 'Moisture', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_alert', 'unique_id': '123456789ABCDEFGH_water_alert', @@ -239,6 +244,7 @@ 'original_name': 'Motion', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_detected', 'unique_id': '123456789ABCDEFGH_motion_detected', @@ -287,6 +293,7 @@ 'original_name': 'Overheated', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'overheated', 'unique_id': '123456789ABCDEFGH_overheated', @@ -335,6 +342,7 @@ 'original_name': 'Overloaded', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'overloaded', 'unique_id': '123456789ABCDEFGH_overloaded', @@ -383,6 +391,7 @@ 'original_name': 'Temperature warning', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_warning', 'unique_id': '123456789ABCDEFGH_temperature_warning', diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index bb4e9f85d58..84cc8f73bf3 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Pair new device', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pair', 'unique_id': '123456789ABCDEFGH_pair', @@ -74,6 +75,7 @@ 'original_name': 'Pan left', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pan_left', 'unique_id': '123456789ABCDEFGH_pan_left', @@ -121,6 +123,7 @@ 'original_name': 'Pan right', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pan_right', 'unique_id': '123456789ABCDEFGH_pan_right', @@ -168,6 +171,7 @@ 'original_name': 'Reset charging contacts consumable', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_contacts_reset', 'unique_id': '123456789ABCDEFGH_charging_contacts_reset', @@ -202,6 +206,7 @@ 'original_name': 'Reset filter consumable', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_reset', 'unique_id': '123456789ABCDEFGH_filter_reset', @@ -236,6 +241,7 @@ 'original_name': 'Reset main brush consumable', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'main_brush_reset', 'unique_id': '123456789ABCDEFGH_main_brush_reset', @@ -270,6 +276,7 @@ 'original_name': 'Reset sensor consumable', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensor_reset', 'unique_id': '123456789ABCDEFGH_sensor_reset', @@ -304,6 +311,7 @@ 'original_name': 'Reset side brush consumable', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'side_brush_reset', 'unique_id': '123456789ABCDEFGH_side_brush_reset', @@ -338,6 +346,7 @@ 'original_name': 'Restart', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reboot', 'unique_id': '123456789ABCDEFGH_reboot', @@ -372,6 +381,7 @@ 'original_name': 'Stop alarm', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': 'my_device_stop_alarm', 'supported_features': 0, 'translation_key': 'stop_alarm', 'unique_id': '123456789ABCDEFGH_stop_alarm', @@ -419,6 +429,7 @@ 'original_name': 'Test alarm', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': 'my_device_test_alarm', 'supported_features': 0, 'translation_key': 'test_alarm', 'unique_id': '123456789ABCDEFGH_test_alarm', @@ -466,6 +477,7 @@ 'original_name': 'Tilt down', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tilt_down', 'unique_id': '123456789ABCDEFGH_tilt_down', @@ -513,6 +525,7 @@ 'original_name': 'Tilt up', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tilt_up', 'unique_id': '123456789ABCDEFGH_tilt_up', @@ -560,6 +573,7 @@ 'original_name': 'Unpair device', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'unpair', 'unique_id': '123456789ABCDEFGH_unpair', diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index e037c2c9e40..f50c5d70362 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -27,6 +27,7 @@ 'original_name': 'Live view', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'live_view', 'unique_id': '123456789ABCDEFGH-live_view', @@ -39,7 +40,6 @@ 'access_token': '1caab5c3b3', 'entity_picture': '/api/camera_proxy/camera.my_camera_live_view?token=1caab5c3b3', 'friendly_name': 'my_camera Live view', - 'frontend_stream_type': , 'supported_features': , }), 'context': , diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index 02492de92b9..df63291175a 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -34,6 +34,7 @@ 'original_name': None, 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABCDEFGH_climate', diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr index 9c395dc2f21..ad0321accef 100644 --- a/tests/components/tplink/snapshots/test_fan.ambr +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -29,6 +29,7 @@ 'original_name': None, 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABCDEFGH', @@ -83,6 +84,7 @@ 'original_name': 'my_fan_0', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABCDEFGH00', @@ -137,6 +139,7 @@ 'original_name': 'my_fan_1', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABCDEFGH01', diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index 0415039a0ce..5ff1d9c5458 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -69,6 +69,7 @@ 'original_name': 'Clean count', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'clean_count', 'unique_id': '123456789ABCDEFGH_clean_count', @@ -125,6 +126,7 @@ 'original_name': 'Pan degrees', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pan_step', 'unique_id': '123456789ABCDEFGH_pan_step', @@ -181,6 +183,7 @@ 'original_name': 'Power protection', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_protection_threshold', 'unique_id': '123456789ABCDEFGH_power_protection_threshold', @@ -237,6 +240,7 @@ 'original_name': 'Smooth off', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smooth_transition_off', 'unique_id': '123456789ABCDEFGH_smooth_transition_off', @@ -293,6 +297,7 @@ 'original_name': 'Smooth on', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smooth_transition_on', 'unique_id': '123456789ABCDEFGH_smooth_transition_on', @@ -349,6 +354,7 @@ 'original_name': 'Temperature offset', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_offset', 'unique_id': '123456789ABCDEFGH_temperature_offset', @@ -405,6 +411,7 @@ 'original_name': 'Tilt degrees', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tilt_step', 'unique_id': '123456789ABCDEFGH_tilt_step', @@ -461,6 +468,7 @@ 'original_name': 'Turn off in', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_off_minutes', 'unique_id': '123456789ABCDEFGH_auto_off_minutes', diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr index e5191937ee9..9fc5181c45d 100644 --- a/tests/components/tplink/snapshots/test_select.ambr +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -86,6 +86,7 @@ 'original_name': 'Alarm sound', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_sound', 'unique_id': '123456789ABCDEFGH_alarm_sound', @@ -160,6 +161,7 @@ 'original_name': 'Alarm volume', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_volume', 'unique_id': '123456789ABCDEFGH_alarm_volume', @@ -218,6 +220,7 @@ 'original_name': 'Light preset', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_preset', 'unique_id': '123456789ABCDEFGH_light_preset', diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 73fcdc8565d..5c22c2f7d83 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -64,6 +64,7 @@ 'original_name': 'Alarm source', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_source', 'unique_id': '123456789ABCDEFGH_alarm_source', @@ -98,6 +99,7 @@ 'original_name': 'Auto-off at', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_off_at', 'unique_id': '123456789ABCDEFGH_auto_off_at', @@ -148,6 +150,7 @@ 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_level', 'unique_id': '123456789ABCDEFGH_battery_level', @@ -201,6 +204,7 @@ 'original_name': 'Charging contacts remaining', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_contacts_remaining', 'unique_id': '123456789ABCDEFGH_charging_contacts_remaining', @@ -238,6 +242,7 @@ 'original_name': 'Charging contacts used', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charging_contacts_used', 'unique_id': '123456789ABCDEFGH_charging_contacts_used', @@ -268,6 +273,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -277,6 +285,7 @@ 'original_name': 'Cleaning area', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'clean_area', 'unique_id': '123456789ABCDEFGH_clean_area', @@ -296,7 +305,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.2', + 'state': '0.18580608', }) # --- # name: test_states[sensor.my_device_cleaning_progress-entry] @@ -329,6 +338,7 @@ 'original_name': 'Cleaning progress', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'clean_progress', 'unique_id': '123456789ABCDEFGH_clean_progress', @@ -357,6 +367,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -366,6 +379,7 @@ 'original_name': 'Cleaning time', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'clean_time', 'unique_id': '123456789ABCDEFGH_clean_time', @@ -384,7 +398,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '12.00', + 'state': '12.0', }) # --- # name: test_states[sensor.my_device_current-entry] @@ -420,6 +434,7 @@ 'original_name': 'Current', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current', 'unique_id': '123456789ABCDEFGH_current_a', @@ -475,6 +490,7 @@ 'original_name': 'Current consumption', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_consumption', 'unique_id': '123456789ABCDEFGH_current_power_w', @@ -525,6 +541,7 @@ 'original_name': 'Device time', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_time', 'unique_id': '123456789ABCDEFGH_device_time', @@ -574,6 +591,7 @@ 'original_name': 'Error', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vacuum_error', 'unique_id': '123456789ABCDEFGH_vacuum_error', @@ -639,6 +657,7 @@ 'original_name': 'Filter remaining', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_remaining', 'unique_id': '123456789ABCDEFGH_filter_remaining', @@ -676,6 +695,7 @@ 'original_name': 'Filter used', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_used', 'unique_id': '123456789ABCDEFGH_filter_used', @@ -712,6 +732,7 @@ 'original_name': 'Humidity', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', 'unique_id': '123456789ABCDEFGH_humidity', @@ -762,6 +783,7 @@ 'original_name': 'Last clean start', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_clean_timestamp', 'unique_id': '123456789ABCDEFGH_last_clean_timestamp', @@ -799,6 +821,7 @@ 'original_name': 'Last cleaned area', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_clean_area', 'unique_id': '123456789ABCDEFGH_last_clean_area', @@ -838,6 +861,7 @@ 'original_name': 'Last cleaned time', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_clean_time', 'unique_id': '123456789ABCDEFGH_last_clean_time', @@ -872,6 +896,7 @@ 'original_name': 'Last water leak alert', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_alert_timestamp', 'unique_id': '123456789ABCDEFGH_water_alert_timestamp', @@ -923,6 +948,7 @@ 'original_name': 'Main brush remaining', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'main_brush_remaining', 'unique_id': '123456789ABCDEFGH_main_brush_remaining', @@ -960,6 +986,7 @@ 'original_name': 'Main brush used', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'main_brush_used', 'unique_id': '123456789ABCDEFGH_main_brush_used', @@ -994,6 +1021,7 @@ 'original_name': 'On since', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'on_since', 'unique_id': '123456789ABCDEFGH_on_since', @@ -1028,6 +1056,7 @@ 'original_name': 'Report interval', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'report_interval', 'unique_id': '123456789ABCDEFGH_report_interval', @@ -1065,6 +1094,7 @@ 'original_name': 'Sensor remaining', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensor_remaining', 'unique_id': '123456789ABCDEFGH_sensor_remaining', @@ -1102,6 +1132,7 @@ 'original_name': 'Sensor used', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensor_used', 'unique_id': '123456789ABCDEFGH_sensor_used', @@ -1139,6 +1170,7 @@ 'original_name': 'Side brush remaining', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'side_brush_remaining', 'unique_id': '123456789ABCDEFGH_side_brush_remaining', @@ -1176,6 +1208,7 @@ 'original_name': 'Side brush used', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'side_brush_used', 'unique_id': '123456789ABCDEFGH_side_brush_used', @@ -1212,6 +1245,7 @@ 'original_name': 'Signal level', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'signal_level', 'unique_id': '123456789ABCDEFGH_signal_level', @@ -1262,6 +1296,7 @@ 'original_name': 'Signal strength', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rssi', 'unique_id': '123456789ABCDEFGH_rssi', @@ -1296,6 +1331,7 @@ 'original_name': 'SSID', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ssid', 'unique_id': '123456789ABCDEFGH_ssid', @@ -1332,6 +1368,7 @@ 'original_name': 'Temperature', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', 'unique_id': '123456789ABCDEFGH_temperature', @@ -1371,6 +1408,7 @@ 'original_name': "This month's consumption", 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_this_month', 'unique_id': '123456789ABCDEFGH_consumption_this_month', @@ -1426,6 +1464,7 @@ 'original_name': "Today's consumption", 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_today', 'unique_id': '123456789ABCDEFGH_today_energy_kwh', @@ -1481,6 +1520,7 @@ 'original_name': 'Total cleaning area', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_clean_area', 'unique_id': '123456789ABCDEFGH_total_clean_area', @@ -1517,6 +1557,7 @@ 'original_name': 'Total cleaning count', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_clean_count', 'unique_id': '123456789ABCDEFGH_total_clean_count', @@ -1556,6 +1597,7 @@ 'original_name': 'Total cleaning time', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_clean_time', 'unique_id': '123456789ABCDEFGH_total_clean_time', @@ -1595,6 +1637,7 @@ 'original_name': 'Total consumption', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'consumption_total', 'unique_id': '123456789ABCDEFGH_total_energy_kwh', @@ -1650,6 +1693,7 @@ 'original_name': 'Voltage', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage', 'unique_id': '123456789ABCDEFGH_voltage', diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index 7365e449707..761df4fcf21 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -69,6 +69,7 @@ 'original_name': None, 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABCDEFGH', diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index fd398434a07..4b04587db05 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -64,6 +64,7 @@ 'original_name': None, 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABCDEFGH', @@ -111,6 +112,7 @@ 'original_name': 'Auto-off enabled', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_off_enabled', 'unique_id': '123456789ABCDEFGH_auto_off_enabled', @@ -158,6 +160,7 @@ 'original_name': 'Auto-update enabled', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_update_enabled', 'unique_id': '123456789ABCDEFGH_auto_update_enabled', @@ -205,6 +208,7 @@ 'original_name': 'Baby cry detection', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'baby_cry_detection', 'unique_id': '123456789ABCDEFGH_baby_cry_detection', @@ -252,6 +256,7 @@ 'original_name': 'Carpet boost', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'carpet_boost', 'unique_id': '123456789ABCDEFGH_carpet_boost', @@ -299,6 +304,7 @@ 'original_name': 'Child lock', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', 'unique_id': '123456789ABCDEFGH_child_lock', @@ -346,6 +352,7 @@ 'original_name': 'Fan sleep mode', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fan_sleep_mode', 'unique_id': '123456789ABCDEFGH_fan_sleep_mode', @@ -393,6 +400,7 @@ 'original_name': 'LED', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'led', 'unique_id': '123456789ABCDEFGH_led', @@ -440,6 +448,7 @@ 'original_name': 'Motion detection', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_detection', 'unique_id': '123456789ABCDEFGH_motion_detection', @@ -487,6 +496,7 @@ 'original_name': 'Motion sensor', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pir_enabled', 'unique_id': '123456789ABCDEFGH_pir_enabled', @@ -534,6 +544,7 @@ 'original_name': 'Person detection', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'person_detection', 'unique_id': '123456789ABCDEFGH_person_detection', @@ -581,6 +592,7 @@ 'original_name': 'Smooth transitions', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'smooth_transitions', 'unique_id': '123456789ABCDEFGH_smooth_transitions', @@ -628,6 +640,7 @@ 'original_name': 'Tamper detection', 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tamper_detection', 'unique_id': '123456789ABCDEFGH_tamper_detection', diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index e010c9545d1..68d14270b55 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -69,6 +69,7 @@ 'original_name': None, 'platform': 'tplink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vacuum', 'unique_id': '123456789ABCDEFGH-vacuum', diff --git a/tests/components/tplink_omada/snapshots/test_sensor.ambr b/tests/components/tplink_omada/snapshots/test_sensor.ambr index 62167fc9d40..dde4c4b8e7a 100644 --- a/tests/components/tplink_omada/snapshots/test_sensor.ambr +++ b/tests/components/tplink_omada/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'CPU usage', 'platform': 'tplink_omada', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cpu_usage', 'unique_id': '54-AF-97-00-00-01_cpu_usage', @@ -88,6 +89,7 @@ 'original_name': 'Device status', 'platform': 'tplink_omada', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_status', 'unique_id': '54-AF-97-00-00-01_device_status', @@ -147,6 +149,7 @@ 'original_name': 'Memory usage', 'platform': 'tplink_omada', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mem_usage', 'unique_id': '54-AF-97-00-00-01_mem_usage', @@ -198,6 +201,7 @@ 'original_name': 'CPU usage', 'platform': 'tplink_omada', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cpu_usage', 'unique_id': 'AA-BB-CC-DD-EE-FF_cpu_usage', @@ -257,6 +261,7 @@ 'original_name': 'Device status', 'platform': 'tplink_omada', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_status', 'unique_id': 'AA-BB-CC-DD-EE-FF_device_status', @@ -316,6 +321,7 @@ 'original_name': 'Memory usage', 'platform': 'tplink_omada', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mem_usage', 'unique_id': 'AA-BB-CC-DD-EE-FF_mem_usage', diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr index eae97f2aae1..513173248f0 100644 --- a/tests/components/tplink_omada/snapshots/test_switch.ambr +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -92,6 +92,7 @@ 'original_name': 'Port 1 PoE', 'platform': 'tplink_omada', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'poe_control', 'unique_id': '54-AF-97-00-00-01_000000000000000000000001_poe', @@ -139,6 +140,7 @@ 'original_name': 'Port 2 (Renamed Port) PoE', 'platform': 'tplink_omada', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'poe_control', 'unique_id': '54-AF-97-00-00-01_000000000000000000000002_poe', diff --git a/tests/components/traccar_server/test_diagnostics.py b/tests/components/traccar_server/test_diagnostics.py index 738fea1a45d..711c812e6a3 100644 --- a/tests/components/traccar_server/test_diagnostics.py +++ b/tests/components/traccar_server/test_diagnostics.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er diff --git a/tests/components/tractive/snapshots/test_binary_sensor.ambr b/tests/components/tractive/snapshots/test_binary_sensor.ambr index c7252da7a3b..150318cc753 100644 --- a/tests/components/tractive/snapshots/test_binary_sensor.ambr +++ b/tests/components/tractive/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Tracker battery charging', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tracker_battery_charging', 'unique_id': 'pet_id_123_battery_charging', @@ -75,6 +76,7 @@ 'original_name': 'Tracker power saving', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tracker_power_saving', 'unique_id': 'pet_id_123_power_saving', diff --git a/tests/components/tractive/snapshots/test_device_tracker.ambr b/tests/components/tractive/snapshots/test_device_tracker.ambr index ef511299e68..ca8a4b6d48b 100644 --- a/tests/components/tractive/snapshots/test_device_tracker.ambr +++ b/tests/components/tractive/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': 'Tracker', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tracker', 'unique_id': 'pet_id_123', diff --git a/tests/components/tractive/snapshots/test_sensor.ambr b/tests/components/tractive/snapshots/test_sensor.ambr index 4551492e36e..af4222486b1 100644 --- a/tests/components/tractive/snapshots/test_sensor.ambr +++ b/tests/components/tractive/snapshots/test_sensor.ambr @@ -33,6 +33,7 @@ 'original_name': 'Activity', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity', 'unique_id': 'pet_id_123_activity_label', @@ -88,6 +89,7 @@ 'original_name': 'Activity time', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_time', 'unique_id': 'pet_id_123_minutes_active', @@ -139,6 +141,7 @@ 'original_name': 'Calories burned', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calories', 'unique_id': 'pet_id_123_calories', @@ -188,6 +191,7 @@ 'original_name': 'Daily goal', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'daily_goal', 'unique_id': 'pet_id_123_daily_goal', @@ -238,6 +242,7 @@ 'original_name': 'Day sleep', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'minutes_day_sleep', 'unique_id': 'pet_id_123_minutes_day_sleep', @@ -289,6 +294,7 @@ 'original_name': 'Night sleep', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'minutes_night_sleep', 'unique_id': 'pet_id_123_minutes_night_sleep', @@ -340,6 +346,7 @@ 'original_name': 'Rest time', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rest_time', 'unique_id': 'pet_id_123_minutes_rest', @@ -395,6 +402,7 @@ 'original_name': 'Sleep', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sleep', 'unique_id': 'pet_id_123_sleep_label', @@ -448,6 +456,7 @@ 'original_name': 'Tracker battery', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tracker_battery_level', 'unique_id': 'pet_id_123_battery_level', @@ -505,6 +514,7 @@ 'original_name': 'Tracker state', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tracker_state', 'unique_id': 'pet_id_123_tracker_state', diff --git a/tests/components/tractive/snapshots/test_switch.ambr b/tests/components/tractive/snapshots/test_switch.ambr index d443611ef92..f83436e9a60 100644 --- a/tests/components/tractive/snapshots/test_switch.ambr +++ b/tests/components/tractive/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Live tracking', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'live_tracking', 'unique_id': 'pet_id_123_live_tracking', @@ -74,6 +75,7 @@ 'original_name': 'Tracker buzzer', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tracker_buzzer', 'unique_id': 'pet_id_123_buzzer', @@ -121,6 +123,7 @@ 'original_name': 'Tracker LED', 'platform': 'tractive', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tracker_led', 'unique_id': 'pet_id_123_led', diff --git a/tests/components/tractive/test_binary_sensor.py b/tests/components/tractive/test_binary_sensor.py index cd7ffbc3da3..283543d761d 100644 --- a/tests/components/tractive/test_binary_sensor.py +++ b/tests/components/tractive/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tractive/test_device_tracker.py b/tests/components/tractive/test_device_tracker.py index ff9c7ca88ef..6fdbc245662 100644 --- a/tests/components/tractive/test_device_tracker.py +++ b/tests/components/tractive/test_device_tracker.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import SourceType from homeassistant.const import Platform diff --git a/tests/components/tractive/test_diagnostics.py b/tests/components/tractive/test_diagnostics.py index ce07b4d6e2a..1dcba8e12dd 100644 --- a/tests/components/tractive/test_diagnostics.py +++ b/tests/components/tractive/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/tractive/test_sensor.py b/tests/components/tractive/test_sensor.py index b53cc3c4d64..30463cd0bd9 100644 --- a/tests/components/tractive/test_sensor.py +++ b/tests/components/tractive/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tractive/test_switch.py b/tests/components/tractive/test_switch.py index cc7ce6cf81f..92e4676aef1 100644 --- a/tests/components/tractive/test_switch.py +++ b/tests/components/tractive/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from aiotractive.exceptions import TractiveError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index 4a829bb86d2..4f19c7e3427 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -437,3 +437,50 @@ async def test_unavailable_source( await hass.async_block_till_done() assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + +async def test_invalid_state_handling( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of invalid states in trend sensor.""" + await setup_component( + { + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + }, + ) + + for val in (10, 20, 30, 40, 50, 60): + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == STATE_ON + + # Set an invalid state + hass.states.async_set("sensor.test_state", "invalid") + await hass.async_block_till_done() + + # The trend sensor should handle the invalid state gracefully + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == STATE_ON + + # Check if a warning is logged + assert ( + "Error processing sensor state change for entity_id=sensor.test_state, " + "attribute=None, state=invalid: could not convert string to float: 'invalid'" + ) in caplog.text + + # Set a valid state again + hass.states.async_set("sensor.test_state", 50) + await hass.async_block_till_done() + + # The trend sensor should return to a valid state + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == "on" diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 7ffb18de297..4ff6213d082 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -1,15 +1,95 @@ """Test the Trend integration.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components import trend +from homeassistant.components.trend.config_flow import ConfigFlowHandler from homeassistant.components.trend.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from .conftest import ComponentSetup from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def trend_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a trend config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": sensor_entity_entry.entity_id, + "invert": False, + }, + title="My trend", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_setup_and_remove_config_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -135,3 +215,194 @@ async def test_device_cleaning( trend_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the trend config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the trend config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + + # Check that the trend config entry is removed + assert trend_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the trend config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + + # Check that the trend config entry is not removed + assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert trend_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the trend config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert trend_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the trend config entry is not removed + assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the trend config entry is updated with the new entity ID + assert trend_config_entry.options["entity_id"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + # Check that the trend config entry is not removed + assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index c21db66dfac..171334c136a 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -281,6 +281,7 @@ class MockResultStream(ResultStream): content_type=f"audio/mock-{extension}", engine="test-engine", use_file_cache=True, + supports_streaming_input=True, language="en", options={}, _manager=hass.data[DATA_TTS_MANAGER], diff --git a/tests/components/tts/test_entity.py b/tests/components/tts/test_entity.py index d82ec6a5d2b..8648ca95e93 100644 --- a/tests/components/tts/test_entity.py +++ b/tests/components/tts/test_entity.py @@ -1,5 +1,7 @@ """Tests for the TTS entity.""" +from typing import Any + import pytest from homeassistant.components import tts @@ -142,3 +144,34 @@ async def test_tts_entity_subclass_properties( if record.exc_info is not None ] ) + + +def test_streaming_supported() -> None: + """Test streaming support.""" + base_entity = tts.TextToSpeechEntity() + assert base_entity.async_supports_streaming_input() is False + + class StreamingEntity(tts.TextToSpeechEntity): + async def async_stream_tts_audio(self) -> None: + pass + + streaming_entity = StreamingEntity() + assert streaming_entity.async_supports_streaming_input() is True + + class NonStreamingEntity(tts.TextToSpeechEntity): + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> tts.TtsAudioType: + pass + + non_streaming_entity = NonStreamingEntity() + assert non_streaming_entity.async_supports_streaming_input() is False + + class SyncNonStreamingEntity(tts.TextToSpeechEntity): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> tts.TtsAudioType: + pass + + sync_non_streaming_entity = SyncNonStreamingEntity() + assert sync_non_streaming_entity.async_supports_streaming_input() is False diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index ea281506f3a..ccb62959eba 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -4,7 +4,7 @@ import asyncio from http import HTTPStatus from pathlib import Path from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -1885,6 +1885,7 @@ async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> No 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 stream.supports_streaming_input is False 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()]) @@ -1905,6 +1906,7 @@ async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> No ) mock_tts_entity.async_stream_tts_audio = async_stream_tts_audio + mock_tts_entity.async_supports_streaming_input = Mock(return_value=True) async def stream_message(): """Mock stream message.""" @@ -1913,6 +1915,7 @@ async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> No yield "o" stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) + assert stream.supports_streaming_input is True 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" diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index eb4b09cab5b..8ec0de8765d 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -17,6 +17,7 @@ from homeassistant.setup import async_setup_component from .common import ( DEFAULT_LANG, + MockResultStream, MockTTSEntity, MockTTSProvider, mock_config_entry_setup, @@ -198,6 +199,17 @@ async def test_resolving( assert language == "de_DE" assert mock_get_tts_audio.mock_calls[0][2]["options"] == {"voice": "Paulus"} + # Test with result stream + stream = MockResultStream(hass, "wav", b"") + media = await media_source.async_resolve_media(hass, stream.media_source_id, None) + assert media.url == stream.url + assert media.mime_type == stream.content_type + + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media( + hass, "media-source://tts/-stream-/not-a-valid-token", None + ) + @pytest.mark.parametrize( ("mock_provider", "mock_tts_entity"), diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 0576fcd6a70..915c0f5080e 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -72,6 +72,7 @@ 'original_name': None, 'platform': 'twentemilieu', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calendar', 'unique_id': '12345', diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index b40ac0ba9e6..9e8bb6f7381 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -41,6 +41,7 @@ 'original_name': 'Christmas tree pickup', 'platform': 'twentemilieu', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'christmas_tree_pickup', 'unique_id': 'twentemilieu_12345_tree', @@ -122,6 +123,7 @@ 'original_name': 'Non-recyclable waste pickup', 'platform': 'twentemilieu', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'non_recyclable_waste_pickup', 'unique_id': 'twentemilieu_12345_Non-recyclable', @@ -203,6 +205,7 @@ 'original_name': 'Organic waste pickup', 'platform': 'twentemilieu', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'organic_waste_pickup', 'unique_id': 'twentemilieu_12345_Organic', @@ -284,6 +287,7 @@ 'original_name': 'Packages waste pickup', 'platform': 'twentemilieu', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'packages_waste_pickup', 'unique_id': 'twentemilieu_12345_Plastic', @@ -365,6 +369,7 @@ 'original_name': 'Paper waste pickup', 'platform': 'twentemilieu', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'paper_waste_pickup', 'unique_id': 'twentemilieu_12345_Paper', diff --git a/tests/components/twinkly/snapshots/test_light.ambr b/tests/components/twinkly/snapshots/test_light.ambr index 77a97a0cdd9..5b5137d2b73 100644 --- a/tests/components/twinkly/snapshots/test_light.ambr +++ b/tests/components/twinkly/snapshots/test_light.ambr @@ -35,6 +35,7 @@ 'original_name': None, 'platform': 'twinkly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'light', 'unique_id': '00:2d:13:3b:aa:bb', diff --git a/tests/components/twinkly/snapshots/test_select.ambr b/tests/components/twinkly/snapshots/test_select.ambr index 6700aecd1f2..58d796ea2e4 100644 --- a/tests/components/twinkly/snapshots/test_select.ambr +++ b/tests/components/twinkly/snapshots/test_select.ambr @@ -37,6 +37,7 @@ 'original_name': 'Mode', 'platform': 'twinkly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mode', 'unique_id': '00:2d:13:3b:aa:bb_mode', diff --git a/tests/components/twinkly/test_diagnostics.py b/tests/components/twinkly/test_diagnostics.py index d7ef4dd9b11..b1f75d005b9 100644 --- a/tests/components/twinkly/test_diagnostics.py +++ b/tests/components/twinkly/test_diagnostics.py @@ -1,7 +1,7 @@ """Tests for the diagnostics of the twinkly component.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index f8289cb95e3..670f9c4a381 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from ttls.client import TwinklyError from homeassistant.components.light import ( diff --git a/tests/components/twinkly/test_select.py b/tests/components/twinkly/test_select.py index 103fbe0f634..515ce3c2cb5 100644 --- a/tests/components/twinkly/test_select.py +++ b/tests/components/twinkly/test_select.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNAVAILABLE, Platform diff --git a/tests/components/unifi/snapshots/test_button.ambr b/tests/components/unifi/snapshots/test_button.ambr index 369b0823063..b0fbe9cdbb8 100644 --- a/tests/components/unifi/snapshots/test_button.ambr +++ b/tests/components/unifi/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Regenerate Password', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wlan_regenerate_password', 'unique_id': 'regenerate_password-012345678910111213141516', @@ -75,6 +76,7 @@ 'original_name': 'Port 1 Power Cycle', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'power_cycle-00:00:00:00:01:01_1', @@ -123,6 +125,7 @@ 'original_name': 'Restart', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'device_restart-00:00:00:00:01:01', diff --git a/tests/components/unifi/snapshots/test_device_tracker.ambr b/tests/components/unifi/snapshots/test_device_tracker.ambr index 5d3407e4e8e..2a8af0dd765 100644 --- a/tests/components/unifi/snapshots/test_device_tracker.ambr +++ b/tests/components/unifi/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': 'Switch 1', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:01:01', @@ -77,6 +78,7 @@ 'original_name': 'wd_client_1', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'site_id-00:00:00:00:00:02', @@ -127,6 +129,7 @@ 'original_name': 'ws_client_1', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'site_id-00:00:00:00:00:01', diff --git a/tests/components/unifi/snapshots/test_image.ambr b/tests/components/unifi/snapshots/test_image.ambr index 05cca2c305b..d27e9ade3aa 100644 --- a/tests/components/unifi/snapshots/test_image.ambr +++ b/tests/components/unifi/snapshots/test_image.ambr @@ -27,6 +27,7 @@ 'original_name': 'QR Code', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wlan_qr_code', 'unique_id': 'qr_code-012345678910111213141516', diff --git a/tests/components/unifi/snapshots/test_sensor.ambr b/tests/components/unifi/snapshots/test_sensor.ambr index 4d109f630c5..c0981d47f1f 100644 --- a/tests/components/unifi/snapshots/test_sensor.ambr +++ b/tests/components/unifi/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Clients', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_clients', 'unique_id': 'device_clients-20:00:00:00:01:01', @@ -92,6 +93,7 @@ 'original_name': 'State', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_state', 'unique_id': 'device_state-20:00:00:00:01:01', @@ -148,12 +150,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'device_temperature-20:00:00:00:01:01', @@ -203,6 +209,7 @@ 'original_name': 'Uptime', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'device_uptime-20:00:00:00:01:01', @@ -256,6 +263,7 @@ 'original_name': 'AC Power Budget', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ac_power_budget-01:02:03:04:05:ff', @@ -311,6 +319,7 @@ 'original_name': 'AC Power Consumption', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'ac_power_conumption-01:02:03:04:05:ff', @@ -363,6 +372,7 @@ 'original_name': 'Clients', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_clients', 'unique_id': 'device_clients-01:02:03:04:05:ff', @@ -413,6 +423,7 @@ 'original_name': 'CPU utilization', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_cpu_utilization', 'unique_id': 'cpu_utilization-01:02:03:04:05:ff', @@ -464,6 +475,7 @@ 'original_name': 'Memory utilization', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_memory_utilization', 'unique_id': 'memory_utilization-01:02:03:04:05:ff', @@ -509,12 +521,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outlet 2 Outlet Power', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'outlet_power-01:02:03:04:05:ff_2', @@ -580,6 +596,7 @@ 'original_name': 'State', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_state', 'unique_id': 'device_state-01:02:03:04:05:ff', @@ -642,6 +659,7 @@ 'original_name': 'Uptime', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'device_uptime-01:02:03:04:05:ff', @@ -692,6 +710,7 @@ 'original_name': 'Clients', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_clients', 'unique_id': 'device_clients-10:00:00:00:01:01', @@ -736,12 +755,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Cloudflare WAN2 latency', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'cloudflare_wan2_latency-10:00:00:00:01:01', @@ -788,12 +811,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Cloudflare WAN latency', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'cloudflare_wan_latency-10:00:00:00:01:01', @@ -840,12 +867,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Google WAN2 latency', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'google_wan2_latency-10:00:00:00:01:01', @@ -892,12 +923,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Google WAN latency', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'google_wan_latency-10:00:00:00:01:01', @@ -944,12 +979,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Microsoft WAN2 latency', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'microsoft_wan2_latency-10:00:00:00:01:01', @@ -996,12 +1035,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Microsoft WAN latency', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'microsoft_wan_latency-10:00:00:00:01:01', @@ -1048,12 +1091,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Port 1 PoE Power', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'poe_power-10:00:00:00:01:01_1', @@ -1100,6 +1147,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1109,6 +1159,7 @@ 'original_name': 'Port 1 RX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_bandwidth_rx', 'unique_id': 'port_rx-10:00:00:00:01:01_1', @@ -1128,7 +1179,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.00000', + 'state': '0.0', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_port_1_tx-entry] @@ -1155,6 +1206,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1164,6 +1218,7 @@ 'original_name': 'Port 1 TX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_bandwidth_tx', 'unique_id': 'port_tx-10:00:00:00:01:01_1', @@ -1183,7 +1238,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.00000', + 'state': '0.0', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_port_2_poe_power-entry] @@ -1210,12 +1265,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Port 2 PoE Power', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'poe_power-10:00:00:00:01:01_2', @@ -1262,6 +1321,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1271,6 +1333,7 @@ 'original_name': 'Port 2 RX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_bandwidth_rx', 'unique_id': 'port_rx-10:00:00:00:01:01_2', @@ -1290,7 +1353,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.00000', + 'state': '0.0', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_port_2_tx-entry] @@ -1317,6 +1380,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1326,6 +1392,7 @@ 'original_name': 'Port 2 TX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_bandwidth_tx', 'unique_id': 'port_tx-10:00:00:00:01:01_2', @@ -1345,7 +1412,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.00000', + 'state': '0.0', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_port_3_rx-entry] @@ -1372,6 +1439,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1381,6 +1451,7 @@ 'original_name': 'Port 3 RX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_bandwidth_rx', 'unique_id': 'port_rx-10:00:00:00:01:01_3', @@ -1400,7 +1471,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.00000', + 'state': '0.0', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_port_3_tx-entry] @@ -1427,6 +1498,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1436,6 +1510,7 @@ 'original_name': 'Port 3 TX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_bandwidth_tx', 'unique_id': 'port_tx-10:00:00:00:01:01_3', @@ -1455,7 +1530,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.00000', + 'state': '0.0', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_port_4_poe_power-entry] @@ -1482,12 +1557,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Port 4 PoE Power', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'poe_power-10:00:00:00:01:01_4', @@ -1534,6 +1613,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1543,6 +1625,7 @@ 'original_name': 'Port 4 RX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_bandwidth_rx', 'unique_id': 'port_rx-10:00:00:00:01:01_4', @@ -1562,7 +1645,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.00000', + 'state': '0.0', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_port_4_tx-entry] @@ -1589,6 +1672,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1598,6 +1684,7 @@ 'original_name': 'Port 4 TX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_bandwidth_tx', 'unique_id': 'port_tx-10:00:00:00:01:01_4', @@ -1617,7 +1704,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.00000', + 'state': '0.0', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_state-entry] @@ -1663,6 +1750,7 @@ 'original_name': 'State', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_state', 'unique_id': 'device_state-10:00:00:00:01:01', @@ -1725,6 +1813,7 @@ 'original_name': 'Uptime', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'device_uptime-10:00:00:00:01:01', @@ -1775,6 +1864,7 @@ 'original_name': None, 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wlan_clients', 'unique_id': 'wlan_clients-012345678910111213141516', @@ -1819,12 +1909,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'client_bandwidth_rx', 'unique_id': 'rx-00:00:00:00:00:01', @@ -1871,12 +1965,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'TX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'client_bandwidth_tx', 'unique_id': 'tx-00:00:00:00:00:01', @@ -1927,6 +2025,7 @@ 'original_name': 'Uptime', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'uptime-00:00:00:00:00:01', @@ -1971,12 +2070,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'RX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'client_bandwidth_rx', 'unique_id': 'rx-00:00:00:00:00:02', @@ -2023,12 +2126,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'TX', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'client_bandwidth_tx', 'unique_id': 'tx-00:00:00:00:00:02', @@ -2079,6 +2186,7 @@ 'original_name': 'Uptime', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'uptime-00:00:00:00:00:02', diff --git a/tests/components/unifi/snapshots/test_switch.ambr b/tests/components/unifi/snapshots/test_switch.ambr index c07a4799b5a..017fe237025 100644 --- a/tests/components/unifi/snapshots/test_switch.ambr +++ b/tests/components/unifi/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'block_client', 'unique_id': 'block-00:00:00:00:01:01', @@ -75,6 +76,7 @@ 'original_name': 'Block Media Streaming', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dpi_restriction', 'unique_id': '5f976f4ae3c58f018ec7dff6', @@ -122,6 +124,7 @@ 'original_name': 'Outlet 2', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'outlet-01:02:03:04:05:ff_2', @@ -170,6 +173,7 @@ 'original_name': 'USB Outlet 1', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'outlet-01:02:03:04:05:ff_1', @@ -218,6 +222,7 @@ 'original_name': 'Port 1 PoE', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'poe_port_control', 'unique_id': 'poe-10:00:00:00:01:01_1', @@ -266,6 +271,7 @@ 'original_name': 'Port 2 PoE', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'poe_port_control', 'unique_id': 'poe-10:00:00:00:01:01_2', @@ -314,6 +320,7 @@ 'original_name': 'Port 4 PoE', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'poe_port_control', 'unique_id': 'poe-10:00:00:00:01:01_4', @@ -362,6 +369,7 @@ 'original_name': 'Outlet 1', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'outlet-fc:ec:da:76:4f:5f_1', @@ -410,6 +418,7 @@ 'original_name': None, 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wlan_control', 'unique_id': 'wlan-012345678910111213141516', @@ -458,6 +467,7 @@ 'original_name': 'plex', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_forward_control', 'unique_id': 'port_forward-5a32aa4ee4b0412345678911', @@ -506,6 +516,7 @@ 'original_name': 'Test Traffic Rule', 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'traffic_rule_control', 'unique_id': 'traffic_rule-6452cd9b859d5b11aa002ea1', diff --git a/tests/components/unifi/snapshots/test_update.ambr b/tests/components/unifi/snapshots/test_update.ambr index ef3803ac53d..caa23768857 100644 --- a/tests/components/unifi/snapshots/test_update.ambr +++ b/tests/components/unifi/snapshots/test_update.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'device_update-00:00:00:00:01:01', @@ -87,6 +88,7 @@ 'original_name': None, 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'device_update-00:00:00:00:01:02', @@ -147,6 +149,7 @@ 'original_name': None, 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'device_update-00:00:00:00:01:01', @@ -207,6 +210,7 @@ 'original_name': None, 'platform': 'unifi', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'device_update-00:00:00:00:01:02', diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 94343d12ba2..61bb9718be7 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -7,7 +7,7 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.unifi.const import CONF_SITE_ID diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 39b70344db7..73b986aed87 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -9,7 +9,7 @@ from aiounifi.models.event import EventKey from aiounifi.models.message import MessageKey from freezegun.api import FrozenDateTimeFactory, freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.unifi.const import ( diff --git a/tests/components/unifi/test_image.py b/tests/components/unifi/test_image.py index dc37d7cb8b7..4f0c815ca0c 100644 --- a/tests/components/unifi/test_image.py +++ b/tests/components/unifi/test_image.py @@ -8,7 +8,7 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index ee8b102edaa..8a5b82ff264 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -10,7 +10,7 @@ from aiounifi.models.device import DeviceState from aiounifi.models.message import MessageKey from freezegun.api import FrozenDateTimeFactory, freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -1042,9 +1042,9 @@ async def test_bandwidth_port_sensors( assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 # Verify sensor state - assert hass.states.get("sensor.mock_name_port_1_rx").state == "0.00921" - assert hass.states.get("sensor.mock_name_port_1_tx").state == "0.04089" - assert hass.states.get("sensor.mock_name_port_2_rx").state == "0.01229" + assert hass.states.get("sensor.mock_name_port_1_rx").state == "0.009208" + assert hass.states.get("sensor.mock_name_port_1_tx").state == "0.040888" + assert hass.states.get("sensor.mock_name_port_2_rx").state == "0.012288" assert hass.states.get("sensor.mock_name_port_2_tx").state == "0.02892" # Verify state update @@ -1055,8 +1055,8 @@ async def test_bandwidth_port_sensors( mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("sensor.mock_name_port_1_rx").state == "27648.00000" - assert hass.states.get("sensor.mock_name_port_1_tx").state == "63128.00000" + assert hass.states.get("sensor.mock_name_port_1_rx").state == "27648.0" + assert hass.states.get("sensor.mock_name_port_1_tx").state == "63128.0" # Disable option options = config_entry_options.copy() diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index c8ee786895c..c336c4ef6db 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -7,7 +7,7 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index 7bf4b9aec9d..3b54aa9ebe4 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yarl import URL from homeassistant.components.unifi.const import CONF_SITE_ID diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index 3a283093179..bcd3e89b784 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -48,7 +48,7 @@ async def test_reboot_button( ufp.api.reboot_device = AsyncMock() unique_id = f"{chime.mac}_reboot" - entity_id = "button.test_chime_reboot_device" + entity_id = "button.test_chime_restart" entity = entity_registry.async_get(entity_id) assert entity diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 975e93edf09..34a1d064547 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -12,6 +12,7 @@ from uiprotect.websocket import WebsocketState from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( + CameraCapabilities, CameraEntityFeature, CameraState, CameraWebRTCProvider, @@ -21,6 +22,7 @@ from homeassistant.components.camera import ( async_get_stream_source, async_register_webrtc_provider, ) +from homeassistant.components.camera.helper import get_camera_from_entity_id from homeassistant.components.unifiprotect.const import ( ATTR_BITRATE, ATTR_CHANNEL_ID, @@ -345,9 +347,11 @@ async def test_webrtc_support( camera_high_only.channels[2].is_rtsp_enabled = False await init_entry(hass, ufp, [camera_high_only]) entity_id = validate_default_camera_entity(hass, camera_high_only, 0) - state = hass.states.get(entity_id) - assert state - assert StreamType.WEB_RTC in state.attributes["frontend_stream_type"] + assert hass.states.get(entity_id) + camera_obj = get_camera_from_entity_id(hass, entity_id) + assert camera_obj.camera_capabilities == CameraCapabilities( + {StreamType.HLS, StreamType.WEB_RTC} + ) async def test_adopt( diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 194e46681ce..1a899550204 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -34,22 +34,21 @@ CAMERA_SWITCHES_BASIC = [ d for d in CAMERA_SWITCHES if ( - not d.name.startswith("Detections:") - and d.name - not in {"SSH enabled", "Color night vision", "Tracking: person", "HDR mode"} + not d.translation_key.startswith("detections_") + and d.key not in {"ssh", "color_night_vision", "track_person", "hdr_mode"} ) - or d.name + or d.key in { - "Detections: motion", - "Detections: person", - "Detections: vehicle", - "Detections: animal", + "detections_motion", + "detections_person", + "detections_vehicle", + "detections_animal", } ] CAMERA_SWITCHES_NO_EXTRA = [ d for d in CAMERA_SWITCHES_BASIC - if d.name not in ("High FPS", "Privacy mode", "HDR mode") + if d.key not in ("high_fps", "privacy_mode", "hdr_mode") ] @@ -152,7 +151,7 @@ async def test_switch_setup_light( description = LIGHT_SWITCHES[0] unique_id = f"{light.mac}_{description.key}" - entity_id = f"switch.test_light_{description.name.lower().replace(' ', '_')}" + entity_id = f"switch.test_light_{description.translation_key}" entity = entity_registry.async_get(entity_id) assert entity @@ -194,11 +193,8 @@ async def test_switch_setup_camera_all( description = CAMERA_SWITCHES[0] - description_entity_name = ( - description.name.lower().replace(":", "").replace(" ", "_") - ) unique_id = f"{doorbell.mac}_{description.key}" - entity_id = f"switch.test_camera_{description_entity_name}" + entity_id = f"switch.test_camera_{description.translation_key}" entity = entity_registry.async_get(entity_id) assert entity @@ -243,11 +239,8 @@ async def test_switch_setup_camera_none( description = CAMERA_SWITCHES[0] - description_entity_name = ( - description.name.lower().replace(":", "").replace(" ", "_") - ) unique_id = f"{camera.mac}_{description.key}" - entity_id = f"switch.test_camera_{description_entity_name}" + entity_id = f"switch.test_camera_{description.translation_key}" entity = entity_registry.async_get(entity_id) assert entity diff --git a/tests/components/unifiprotect/test_views.py b/tests/components/unifiprotect/test_views.py index f787089b83f..9e477e1b8e7 100644 --- a/tests/components/unifiprotect/test_views.py +++ b/tests/components/unifiprotect/test_views.py @@ -678,6 +678,7 @@ async def test_video( mock_response.content.iter_chunked = Mock(return_value=content) ufp.api.request = AsyncMock(return_value=mock_response) + ufp.api._raise_for_status = AsyncMock() await init_entry(hass, ufp, [camera]) event_start = fixed_now - timedelta(seconds=30) @@ -722,6 +723,7 @@ async def test_video_entity_id( mock_response.content.iter_chunked = Mock(return_value=content) ufp.api.request = AsyncMock(return_value=mock_response) + ufp.api._raise_for_status = AsyncMock() await init_entry(hass, ufp, [camera]) event_start = fixed_now - timedelta(seconds=30) @@ -937,6 +939,7 @@ async def test_event_video( mock_response.content.iter_chunked = Mock(return_value=content) ufp.api.request = AsyncMock(return_value=mock_response) + ufp.api._raise_for_status = AsyncMock() await init_entry(hass, ufp, [camera]) event_start = fixed_now - timedelta(seconds=30) event = Event( diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 06ffe16ab87..ddd6fdf0189 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -109,8 +109,10 @@ def ids_from_device_description( entity_name = normalize_name(device.display_name) - if description.name and isinstance(description.name, str): - description_entity_name = normalize_name(description.name) + if getattr(description, "translation_key", None): + description_entity_name = normalize_name(description.translation_key) + elif getattr(description, "device_class", None): + description_entity_name = normalize_name(description.device_class) else: description_entity_name = normalize_name(description.key) diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index d6d896dbcec..5c9ed6d4683 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -41,6 +41,7 @@ 'original_name': None, 'platform': 'uptime', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unit_of_measurement': None, diff --git a/tests/components/uptimerobot/test_switch.py b/tests/components/uptimerobot/test_switch.py index 8c2cffe504a..48e9da05720 100644 --- a/tests/components/uptimerobot/test_switch.py +++ b/tests/components/uptimerobot/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from pyuptimerobot import UptimeRobotAuthenticationException +from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .common import ( MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, @@ -128,18 +129,20 @@ async def test_authentication_error( assert config_entry_reauth.assert_called -async def test_refresh_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test authentication error turning switch on/off.""" +async def test_action_execution_failure(hass: HomeAssistant) -> None: + """Test turning switch on/off failure.""" await setup_uptimerobot_integration(hass) entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) assert entity.state == STATE_ON - with patch( - "homeassistant.helpers.update_coordinator.DataUpdateCoordinator.async_request_refresh" - ) as coordinator_refresh: + with ( + patch( + "pyuptimerobot.UptimeRobot.async_edit_monitor", + side_effect=UptimeRobotException, + ), + pytest.raises(HomeAssistantError) as exc_info, + ): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -147,12 +150,14 @@ async def test_refresh_data( blocking=True, ) - assert coordinator_refresh.assert_called + assert exc_info.value.translation_domain == "uptimerobot" + assert exc_info.value.translation_key == "api_exception" + assert exc_info.value.translation_placeholders == { + "error": "UptimeRobotException()" + } -async def test_switch_api_failure( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_switch_api_failure(hass: HomeAssistant) -> None: """Test general exception turning switch on/off.""" await setup_uptimerobot_integration(hass) @@ -163,11 +168,16 @@ async def test_switch_api_failure( "pyuptimerobot.UptimeRobot.async_edit_monitor", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: UPTIMEROBOT_SWITCH_TEST_ENTITY}, - blocking=True, - ) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: UPTIMEROBOT_SWITCH_TEST_ENTITY}, + blocking=True, + ) - assert "API exception" in caplog.text + assert exc_info.value.translation_domain == "uptimerobot" + assert exc_info.value.translation_key == "api_exception" + assert exc_info.value.translation_placeholders == { + "error": "test error from API." + } diff --git a/tests/components/utility_meter/test_diagnostics.py b/tests/components/utility_meter/test_diagnostics.py index 8be5f949940..88521a91b7f 100644 --- a/tests/components/utility_meter/test_diagnostics.py +++ b/tests/components/utility_meter/test_diagnostics.py @@ -3,7 +3,7 @@ from aiohttp.test_utils import TestClient from freezegun import freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.auth.models import Credentials diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index eba7cf913db..ea4af741e19 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -3,10 +3,12 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch from freezegun import freeze_time import pytest +from homeassistant.components import utility_meter from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -16,7 +18,9 @@ from homeassistant.components.utility_meter import ( select as um_select, sensor as um_sensor, ) +from homeassistant.components.utility_meter.config_flow import ConfigFlowHandler from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -25,14 +29,94 @@ from homeassistant.const import ( Platform, UnitOfEnergy, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, mock_restore_cache +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def utility_meter_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, + tariffs: list[str], +) -> MockConfigEntry: + """Fixture to create a utility_meter config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": sensor_entity_entry.entity_id, + "tariffs": tariffs, + }, + title="My utility meter", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_restore_state(hass: HomeAssistant) -> None: """Test utility sensor restore state.""" config = { @@ -533,3 +617,286 @@ async def test_device_cleaning( utility_meter_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], +) -> None: + """Test the utility_meter config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the utility_meter config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + # Remove the source sensor from the device + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the utility_meter config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries + + # Move the source sensor to another device + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the utility_meter config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert utility_meter_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + # Change the source entity's entity ID + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the utility_meter config entry is updated with the new entity ID + assert utility_meter_config_entry.options["source"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == [] diff --git a/tests/components/v2c/snapshots/test_sensor.ambr b/tests/components/v2c/snapshots/test_sensor.ambr index 46054b21324..3ff711383d7 100644 --- a/tests/components/v2c/snapshots/test_sensor.ambr +++ b/tests/components/v2c/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Battery power', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_power', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_battery_power', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charge energy', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_energy', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_charge_energy', @@ -127,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charge power', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_power', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_charge_power', @@ -179,12 +191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Charge time', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'charge_time', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_charge_time', @@ -231,12 +247,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'House power', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'house_power', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_house_power', @@ -283,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Installation voltage', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_installation', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_voltage_installation', @@ -339,6 +363,7 @@ 'original_name': 'IP address', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ip_address', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_ip_address', @@ -424,6 +449,7 @@ 'original_name': 'Meter error', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meter_error', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_meter_error', @@ -505,12 +531,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Photovoltaic power', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fv_power', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_fv_power', @@ -563,6 +593,7 @@ 'original_name': 'Signal status', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'signal_status', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_signal_status', @@ -611,6 +642,7 @@ 'original_name': 'SSID', 'platform': 'v2c', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ssid', 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_ssid', diff --git a/tests/components/v2c/test_diagnostics.py b/tests/components/v2c/test_diagnostics.py index eafbd68e6fc..6371b2480e8 100644 --- a/tests/components/v2c/test_diagnostics.py +++ b/tests/components/v2c/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.config_entries import ConfigEntry diff --git a/tests/components/v2c/test_sensor.py b/tests/components/v2c/test_sensor.py index 430f91647dd..11dcfe5e4a5 100644 --- a/tests/components/v2c/test_sensor.py +++ b/tests/components/v2c/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.v2c.sensor import _METER_ERROR_OPTIONS from homeassistant.const import Platform diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 65418790280..f7cbeb7a052 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -85,6 +85,7 @@ def mock_module_no_subdevices( module.get_type_name.return_value = "VMB4RYLD" module.get_addresses.return_value = [1, 2, 3, 4] module.get_name.return_value = "BedRoom" + module.get_serial.return_value = "a1b2c3d4e5f6" module.get_sw_version.return_value = "1.0.0" module.is_loaded.return_value = True module.get_channels.return_value = {} @@ -98,6 +99,7 @@ def mock_module_subdevices() -> AsyncMock: module.get_type_name.return_value = "VMB2BLE" module.get_addresses.return_value = [88] module.get_name.return_value = "Kitchen" + module.get_serial.return_value = "a1b2c3d4e5f6" module.get_sw_version.return_value = "2.0.0" module.is_loaded.return_value = True module.get_channels.return_value = {} diff --git a/tests/components/velbus/snapshots/test_binary_sensor.ambr b/tests/components/velbus/snapshots/test_binary_sensor.ambr index 70db53257a1..6ba8ad096c0 100644 --- a/tests/components/velbus/snapshots/test_binary_sensor.ambr +++ b/tests/components/velbus/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'ButtonOn', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a1b2c3d4e5f6-1', diff --git a/tests/components/velbus/snapshots/test_button.ambr b/tests/components/velbus/snapshots/test_button.ambr index 856ebdb1e21..7b06cbfb548 100644 --- a/tests/components/velbus/snapshots/test_button.ambr +++ b/tests/components/velbus/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'ButtonOn', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a1b2c3d4e5f6-1', diff --git a/tests/components/velbus/snapshots/test_climate.ambr b/tests/components/velbus/snapshots/test_climate.ambr index 1d1f49d14d9..027f06c3858 100644 --- a/tests/components/velbus/snapshots/test_climate.ambr +++ b/tests/components/velbus/snapshots/test_climate.ambr @@ -40,6 +40,7 @@ 'original_name': 'Temperature', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'asdfghjk-3', diff --git a/tests/components/velbus/snapshots/test_cover.ambr b/tests/components/velbus/snapshots/test_cover.ambr index 0be18034bc0..53b6c921e23 100644 --- a/tests/components/velbus/snapshots/test_cover.ambr +++ b/tests/components/velbus/snapshots/test_cover.ambr @@ -27,6 +27,7 @@ 'original_name': 'CoverName', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1234-9', @@ -76,6 +77,7 @@ 'original_name': 'CoverNameNoPos', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '12345-11', diff --git a/tests/components/velbus/snapshots/test_light.ambr b/tests/components/velbus/snapshots/test_light.ambr index 6dd2ca4939d..44240415797 100644 --- a/tests/components/velbus/snapshots/test_light.ambr +++ b/tests/components/velbus/snapshots/test_light.ambr @@ -31,6 +31,7 @@ 'original_name': 'LED ButtonOn', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'a1b2c3d4e5f6-1', @@ -87,6 +88,7 @@ 'original_name': 'Dimmer', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'a1b2c3d4e5f6g7-10', diff --git a/tests/components/velbus/snapshots/test_select.ambr b/tests/components/velbus/snapshots/test_select.ambr index 94bb109fc71..1137563698d 100644 --- a/tests/components/velbus/snapshots/test_select.ambr +++ b/tests/components/velbus/snapshots/test_select.ambr @@ -34,6 +34,7 @@ 'original_name': 'select', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'qwerty1234567-33-program_select', diff --git a/tests/components/velbus/snapshots/test_sensor.ambr b/tests/components/velbus/snapshots/test_sensor.ambr index 6f562f399af..dc79663865f 100644 --- a/tests/components/velbus/snapshots/test_sensor.ambr +++ b/tests/components/velbus/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'ButtonCounter', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a1b2c3d4e5f6-2', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': 'mdi:counter', 'original_name': 'ButtonCounter-counter', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a1b2c3d4e5f6-2-counter', @@ -134,6 +142,7 @@ 'original_name': 'LightSensor', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a1b2c3d4e5f6-4', @@ -185,6 +194,7 @@ 'original_name': 'SensorNumber', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a1b2c3d4e5f6-3', @@ -230,12 +240,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'asdfghjk-3', diff --git a/tests/components/velbus/snapshots/test_switch.ambr b/tests/components/velbus/snapshots/test_switch.ambr index 60458b196a8..7eb886cdd7b 100644 --- a/tests/components/velbus/snapshots/test_switch.ambr +++ b/tests/components/velbus/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'RelayName', 'platform': 'velbus', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'qwerty123-55', diff --git a/tests/components/velbus/test_diagnostics.py b/tests/components/velbus/test_diagnostics.py index af84115ff14..74a0b4911de 100644 --- a/tests/components/velbus/test_diagnostics.py +++ b/tests/components/velbus/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Velbus diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/vera/test_sensor.py b/tests/components/vera/test_sensor.py index c31845b80af..64873000c7b 100644 --- a/tests/components/vera/test_sensor.py +++ b/tests/components/vera/test_sensor.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock import pyvera as pv +from homeassistant.components.sensor import async_rounded_state from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, LIGHT_LUX, PERCENTAGE from homeassistant.core import HomeAssistant @@ -46,7 +47,7 @@ async def run_sensor_test( update_callback(vera_device) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == state_value + assert async_rounded_state(hass, entity_id, state) == state_value if assert_unit_of_measurement: assert ( state.attributes[ATTR_UNIT_OF_MEASUREMENT] == assert_unit_of_measurement @@ -66,7 +67,7 @@ async def test_temperature_sensor_f( vera_component_factory=vera_component_factory, category=pv.CATEGORY_TEMPERATURE_SENSOR, class_property="temperature", - assert_states=(("33", "1"), ("44", "7")), + assert_states=(("33", "0.6"), ("44", "6.7")), setup_callback=setup_callback, ) @@ -80,7 +81,7 @@ async def test_temperature_sensor_c( vera_component_factory=vera_component_factory, category=pv.CATEGORY_TEMPERATURE_SENSOR, class_property="temperature", - assert_states=(("33", "33"), ("44", "44")), + assert_states=(("33", "33.0"), ("44", "44.0")), ) diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 5795c977120..cf2f49ff28f 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_FAN = "fan.SmartTowerFan" + ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index df6ebbdf6e7..32f23101755 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -198,6 +198,26 @@ async def install_humidifier_device( await hass.async_block_till_done() +@pytest.fixture(name="fan_config_entry") +async def fan_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `SmartTowerFan`.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + + device_name = "SmartTowerFan" + mock_multiple_device_responses(requests_mock, [device_name]) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 92473647a39..fe330b82ca7 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -68,6 +68,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vesync', 'unique_id': 'air-purifier', @@ -167,6 +168,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vesync', 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', @@ -267,6 +269,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vesync', 'unique_id': '400s-purifier', @@ -368,6 +371,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vesync', 'unique_id': '600s-purifier', @@ -640,8 +644,8 @@ 'preset_modes': list([ 'advancedSleep', 'auto', - 'turbo', 'normal', + 'turbo', ]), }), 'config_entry_id': , @@ -666,6 +670,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'vesync', 'unique_id': 'smarttowerfan', @@ -682,12 +687,12 @@ 'night_light': 'off', 'percentage': None, 'percentage_step': 7.6923076923076925, - 'preset_mode': None, + 'preset_mode': 'normal', 'preset_modes': list([ 'advancedSleep', 'auto', - 'turbo', 'normal', + 'turbo', ]), 'screen_status': False, 'supported_features': , diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index bed711b1040..20bf56ef9c4 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -223,6 +223,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'dimmable-bulb', @@ -315,6 +316,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'dimmable-switch', @@ -569,6 +571,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'tunable-bulb', diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index ecae8fa7674..a47de22f68b 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -65,6 +65,7 @@ 'original_name': 'Filter lifetime', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': 'air-purifier-filter-life', @@ -97,6 +98,7 @@ 'original_name': 'Air quality', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': 'air-purifier-air-quality', @@ -198,6 +200,7 @@ 'original_name': 'Filter lifetime', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-filter-life', @@ -286,6 +289,7 @@ 'original_name': 'Filter lifetime', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': '400s-purifier-filter-life', @@ -318,6 +322,7 @@ 'original_name': 'Air quality', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '400s-purifier-air-quality', @@ -352,6 +357,7 @@ 'original_name': 'PM2.5', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '400s-purifier-pm25', @@ -469,6 +475,7 @@ 'original_name': 'Filter lifetime', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': '600s-purifier-filter-life', @@ -501,6 +508,7 @@ 'original_name': 'Air quality', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '600s-purifier-air-quality', @@ -535,6 +543,7 @@ 'original_name': 'PM2.5', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '600s-purifier-pm25', @@ -730,6 +739,7 @@ 'original_name': 'Humidity', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '200s-humidifier4321-humidity', @@ -819,6 +829,7 @@ 'original_name': 'Humidity', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '600s-humidifier-humidity', @@ -902,12 +913,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current power', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_power', 'unique_id': 'outlet-power', @@ -936,12 +951,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy use today', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_today', 'unique_id': 'outlet-energy', @@ -970,12 +989,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy use weekly', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_week', 'unique_id': 'outlet-energy-weekly', @@ -1004,12 +1027,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy use monthly', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_month', 'unique_id': 'outlet-energy-monthly', @@ -1038,12 +1065,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy use yearly', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_year', 'unique_id': 'outlet-energy-yearly', @@ -1072,12 +1103,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current voltage', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_voltage', 'unique_id': 'outlet-voltage', diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index f25aaf3d51b..edd2eee8b1f 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -63,6 +63,7 @@ 'original_name': 'Display', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display', 'unique_id': 'air-purifier-display', @@ -147,6 +148,7 @@ 'original_name': 'Display', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display', 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-display', @@ -231,6 +233,7 @@ 'original_name': 'Display', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display', 'unique_id': '400s-purifier-display', @@ -315,6 +318,7 @@ 'original_name': 'Display', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display', 'unique_id': '600s-purifier-display', @@ -477,6 +481,7 @@ 'original_name': 'Display', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display', 'unique_id': '200s-humidifier4321-display', @@ -561,6 +566,7 @@ 'original_name': 'Display', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display', 'unique_id': '600s-humidifier-display', @@ -645,6 +651,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'outlet-device_status', @@ -730,6 +737,7 @@ 'original_name': 'Display', 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'display', 'unique_id': 'smarttowerfan-display', @@ -853,6 +861,7 @@ 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'switch-device_status', diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index 25aa5337281..c2b789a932e 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch from pyvesync.helpers import Helpers -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.matchers import path_type from homeassistant.components.vesync.const import DOMAIN diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index 4d444036a60..cf572e5b981 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -1,17 +1,24 @@ """Tests for the fan module.""" +from contextlib import nullcontext +from unittest.mock import patch + import pytest import requests_mock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.fan import ATTR_PRESET_MODE, 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 device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, mock_devices_response +from .common import ALL_DEVICE_NAMES, ENTITY_FAN, mock_devices_response from tests.common import MockConfigEntry +NoException = nullcontext() + @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) async def test_fan_state( @@ -49,3 +56,105 @@ async def test_fan_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.VeSyncTowerFan.turn_on"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + ], +) +async def test_turn_on_off_success( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test turn_on and turn_off method.""" + + with ( + patch(command, return_value=True) as method_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_FAN}, + 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.VeSyncTowerFan.turn_on"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + ], +) +async def test_turn_on_off_raises_error( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test turn_on and turn_off raises errors when fails.""" + + # returns False indicating failure in which case raises HomeAssistantError. + with ( + patch( + command, + return_value=False, + ) as method_mock, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_FAN}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_preset_mode( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of value in set_preset_mode method. Does this via turn on as it increases test coverage.""" + + # If VeSyncTowerFan.normal_mode fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncTowerFan.normal_mode", + return_value=api_response, + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_FAN, ATTR_PRESET_MODE: "normal"}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() diff --git a/tests/components/vesync/test_light.py b/tests/components/vesync/test_light.py index 866e6b295bf..7300e28e406 100644 --- a/tests/components/vesync/test_light.py +++ b/tests/components/vesync/test_light.py @@ -2,7 +2,7 @@ import pytest import requests_mock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py index 04d759de584..d4e6abcdbab 100644 --- a/tests/components/vesync/test_sensor.py +++ b/tests/components/vesync/test_sensor.py @@ -2,7 +2,7 @@ import pytest import requests_mock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/vesync/test_switch.py b/tests/components/vesync/test_switch.py index e5d5986b364..b0af5afc5d2 100644 --- a/tests/components/vesync/test_switch.py +++ b/tests/components/vesync/test_switch.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest import requests_mock -from syrupy import SnapshotAssertion +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 diff --git a/tests/components/vicare/snapshots/test_binary_sensor.ambr b/tests/components/vicare/snapshots/test_binary_sensor.ambr index 93e407ea505..7a6e09c55a5 100644 --- a/tests/components/vicare/snapshots/test_binary_sensor.ambr +++ b/tests/components/vicare/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Burner', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'burner', 'unique_id': 'gateway0_deviceSerialVitodens300W-burner_active-0', @@ -75,6 +76,7 @@ 'original_name': 'Circulation pump', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'circulation_pump', 'unique_id': 'gateway0_deviceSerialVitodens300W-circulationpump_active-0', @@ -123,6 +125,7 @@ 'original_name': 'Circulation pump', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'circulation_pump', 'unique_id': 'gateway0_deviceSerialVitodens300W-circulationpump_active-1', @@ -171,6 +174,7 @@ 'original_name': 'DHW charging', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'domestic_hot_water_charging', 'unique_id': 'gateway0_deviceSerialVitodens300W-charging_active', @@ -219,6 +223,7 @@ 'original_name': 'DHW circulation pump', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'domestic_hot_water_circulation_pump', 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_circulationpump_active', @@ -267,6 +272,7 @@ 'original_name': 'DHW pump', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'domestic_hot_water_pump', 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_pump_active', @@ -315,6 +321,7 @@ 'original_name': 'Frost protection', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'frost_protection', 'unique_id': 'gateway0_deviceSerialVitodens300W-frost_protection_active-0', @@ -362,6 +369,7 @@ 'original_name': 'Frost protection', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'frost_protection', 'unique_id': 'gateway0_deviceSerialVitodens300W-frost_protection_active-1', @@ -409,6 +417,7 @@ 'original_name': 'One-time charge', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'one_time_charge', 'unique_id': 'gateway0_deviceSerialVitodens300W-one_time_charge', diff --git a/tests/components/vicare/snapshots/test_button.ambr b/tests/components/vicare/snapshots/test_button.ambr index 17dfc29e96e..445af364520 100644 --- a/tests/components/vicare/snapshots/test_button.ambr +++ b/tests/components/vicare/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Activate one-time charge', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activate_onetimecharge', 'unique_id': 'gateway0_deviceSerialVitodens300W-activate_onetimecharge', diff --git a/tests/components/vicare/snapshots/test_climate.ambr b/tests/components/vicare/snapshots/test_climate.ambr index e1709acea42..4ae868ab4b4 100644 --- a/tests/components/vicare/snapshots/test_climate.ambr +++ b/tests/components/vicare/snapshots/test_climate.ambr @@ -39,6 +39,7 @@ 'original_name': 'Heating', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'heating', 'unique_id': 'gateway0_deviceSerialVitodens300W-heating-0', @@ -123,6 +124,7 @@ 'original_name': 'Heating', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'heating', 'unique_id': 'gateway0_deviceSerialVitodens300W-heating-1', diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 2a44fb87b65..e6f494c0fd1 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -34,6 +34,7 @@ 'original_name': 'Ventilation', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'ventilation', 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation', @@ -103,6 +104,7 @@ 'original_name': 'Ventilation', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'ventilation', 'unique_id': 'gateway1_deviceId1-ventilation', @@ -171,6 +173,7 @@ 'original_name': 'Ventilation', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'ventilation', 'unique_id': 'gateway2_################-ventilation', diff --git a/tests/components/vicare/snapshots/test_number.ambr b/tests/components/vicare/snapshots/test_number.ambr index b26d2d33590..729d1403ad8 100644 --- a/tests/components/vicare/snapshots/test_number.ambr +++ b/tests/components/vicare/snapshots/test_number.ambr @@ -32,6 +32,7 @@ 'original_name': 'Comfort temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'comfort_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-comfort_temperature-0', @@ -90,6 +91,7 @@ 'original_name': 'Comfort temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'comfort_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-comfort_temperature-1', @@ -148,6 +150,7 @@ 'original_name': 'DHW temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhw_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_temperature', @@ -206,6 +209,7 @@ 'original_name': 'Heating curve shift', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heating_curve_shift', 'unique_id': 'gateway0_deviceSerialVitodens300W-heating curve shift-0', @@ -264,6 +268,7 @@ 'original_name': 'Heating curve shift', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heating_curve_shift', 'unique_id': 'gateway0_deviceSerialVitodens300W-heating curve shift-1', @@ -322,6 +327,7 @@ 'original_name': 'Heating curve slope', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heating_curve_slope', 'unique_id': 'gateway0_deviceSerialVitodens300W-heating curve slope-0', @@ -378,6 +384,7 @@ 'original_name': 'Heating curve slope', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heating_curve_slope', 'unique_id': 'gateway0_deviceSerialVitodens300W-heating curve slope-1', @@ -434,6 +441,7 @@ 'original_name': 'Normal temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'normal_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-normal_temperature-0', @@ -492,6 +500,7 @@ 'original_name': 'Normal temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'normal_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-normal_temperature-1', @@ -550,6 +559,7 @@ 'original_name': 'Reduced temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reduced_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-reduced_temperature-0', @@ -608,6 +618,7 @@ 'original_name': 'Reduced temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reduced_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-reduced_temperature-1', diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index a0d4bf374c8..85da1f1d948 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Boiler temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'boiler_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-boiler_temperature', @@ -81,6 +85,7 @@ 'original_name': 'Burner hours', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'burner_hours', 'unique_id': 'gateway0_deviceSerialVitodens300W-burner_hours-0', @@ -132,6 +137,7 @@ 'original_name': 'Burner modulation', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'burner_modulation', 'unique_id': 'gateway0_deviceSerialVitodens300W-burner_modulation-0', @@ -183,6 +189,7 @@ 'original_name': 'Burner starts', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'burner_starts', 'unique_id': 'gateway0_deviceSerialVitodens300W-burner_starts-0', @@ -233,6 +240,7 @@ 'original_name': 'DHW gas consumption this month', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hotwater_gas_consumption_heating_this_month', 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_gas_consumption_heating_this_month', @@ -283,6 +291,7 @@ 'original_name': 'DHW gas consumption this week', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hotwater_gas_consumption_heating_this_week', 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_gas_consumption_heating_this_week', @@ -333,6 +342,7 @@ 'original_name': 'DHW gas consumption this year', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hotwater_gas_consumption_heating_this_year', 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_gas_consumption_heating_this_year', @@ -383,6 +393,7 @@ 'original_name': 'DHW gas consumption today', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hotwater_gas_consumption_today', 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_gas_consumption_today', @@ -427,12 +438,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHW max temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hotwater_max_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_max_temperature', @@ -479,12 +494,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHW min temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hotwater_min_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_min_temperature', @@ -531,12 +550,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Electricity consumption this week', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_consumption_this_week', 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this week', @@ -583,12 +606,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Electricity consumption this year', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_consumption_this_year', 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this year', @@ -635,12 +662,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Electricity consumption today', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_consumption_today', 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption today', @@ -687,12 +718,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power consumption this month', 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', @@ -745,6 +780,7 @@ 'original_name': 'Heating gas consumption this month', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gas_consumption_heating_this_month', 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_month', @@ -795,6 +831,7 @@ 'original_name': 'Heating gas consumption this week', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gas_consumption_heating_this_week', 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_week', @@ -845,6 +882,7 @@ 'original_name': 'Heating gas consumption this year', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gas_consumption_heating_this_year', 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_year', @@ -895,6 +933,7 @@ 'original_name': 'Heating gas consumption today', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gas_consumption_heating_today', 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_today', @@ -939,12 +978,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-outside_temperature', @@ -991,12 +1034,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Supply temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supply_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-supply_temperature-0', @@ -1043,12 +1090,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Supply temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supply_temperature', 'unique_id': 'gateway0_deviceSerialVitodens300W-supply_temperature-1', @@ -1095,12 +1146,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Buffer main temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'buffer_main_temperature', 'unique_id': 'gateway0_deviceSerialVitocal250A-buffer main temperature', @@ -1153,6 +1208,7 @@ 'original_name': 'Compressor hours', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'compressor_hours', 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_hours-0', @@ -1202,6 +1258,7 @@ 'original_name': 'Compressor phase', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'compressor_phase', 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_phase-0', @@ -1251,6 +1308,7 @@ 'original_name': 'Compressor starts', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'compressor_starts', 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_starts-0', @@ -1295,12 +1353,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHW electricity consumption last seven days', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_summary_dhw_consumption_heating_lastsevendays', 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_dhw_consumption_heating_lastsevendays', @@ -1347,12 +1409,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHW electricity consumption this month', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_dhw_summary_consumption_heating_currentmonth', 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentmonth', @@ -1399,12 +1465,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHW electricity consumption this year', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_dhw_summary_consumption_heating_currentyear', 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentyear', @@ -1451,12 +1521,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHW electricity consumption today', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_dhw_summary_consumption_heating_currentday', 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentday', @@ -1503,12 +1577,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHW max temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hotwater_max_temperature', 'unique_id': 'gateway0_deviceSerialVitocal250A-hotwater_max_temperature', @@ -1555,12 +1633,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHW min temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hotwater_min_temperature', 'unique_id': 'gateway0_deviceSerialVitocal250A-hotwater_min_temperature', @@ -1607,12 +1689,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'DHW storage temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhw_storage_temperature', 'unique_id': 'gateway0_deviceSerialVitocal250A-dhw_storage_temperature', @@ -1659,12 +1745,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Electricity consumption today', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_consumption_today', 'unique_id': 'gateway0_deviceSerialVitocal250A-power consumption today', @@ -1711,12 +1801,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Heating electricity consumption last seven days', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_summary_consumption_heating_lastsevendays', 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_lastsevendays', @@ -1763,12 +1857,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Heating electricity consumption this month', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_summary_consumption_heating_currentmonth', 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentmonth', @@ -1815,12 +1913,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Heating electricity consumption this year', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_summary_consumption_heating_currentyear', 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentyear', @@ -1867,12 +1969,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Heating electricity consumption today', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_summary_consumption_heating_currentday', 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentday', @@ -1925,6 +2031,7 @@ 'original_name': 'Heating rod hours', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heating_rod_hours', 'unique_id': 'gateway0_deviceSerialVitocal250A-heating_rod_hours', @@ -1976,6 +2083,7 @@ 'original_name': 'Heating rod starts', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heating_rod_starts', 'unique_id': 'gateway0_deviceSerialVitocal250A-heating_rod_starts', @@ -2020,12 +2128,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'gateway0_deviceSerialVitocal250A-outside_temperature', @@ -2072,12 +2184,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Primary circuit supply temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'primary_circuit_supply_temperature', 'unique_id': 'gateway0_deviceSerialVitocal250A-primary_circuit_supply_temperature', @@ -2124,12 +2240,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Return temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'return_temperature', 'unique_id': 'gateway0_deviceSerialVitocal250A-return_temperature', @@ -2182,6 +2302,7 @@ 'original_name': 'Seasonal performance factor', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'spf_total', 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_total', @@ -2232,6 +2353,7 @@ 'original_name': 'Seasonal performance factor - domestic hot water', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'spf_dhw', 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_dhw', @@ -2282,6 +2404,7 @@ 'original_name': 'Seasonal performance factor - heating', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'spf_heating', 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_heating', @@ -2326,12 +2449,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Secondary circuit supply temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'secondary_circuit_supply_temperature', 'unique_id': 'gateway0_deviceSerialVitocal250A-secondary_circuit_supply_temperature', @@ -2384,6 +2511,7 @@ 'original_name': 'Supply pressure', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supply_pressure', 'unique_id': 'gateway0_deviceSerialVitocal250A-supply_pressure', @@ -2429,12 +2557,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Supply temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'supply_temperature', 'unique_id': 'gateway0_deviceSerialVitocal250A-supply_temperature-1', @@ -2487,6 +2619,7 @@ 'original_name': 'Volumetric flow', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volumetric_flow', 'unique_id': 'gateway0_deviceSerialVitocal250A-volumetric_flow', @@ -2544,6 +2677,7 @@ 'original_name': 'Ventilation level', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ventilation_level', 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_level', @@ -2608,6 +2742,7 @@ 'original_name': 'Ventilation reason', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ventilation_reason', 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_reason', @@ -2666,6 +2801,7 @@ 'original_name': 'Battery', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'gateway0_zigbee_d87a3bfffe5d844a-battery_level', @@ -2718,6 +2854,7 @@ 'original_name': 'Humidity', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'gateway0_zigbee_d87a3bfffe5d844a-room_humidity', @@ -2764,12 +2901,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'gateway0_zigbee_d87a3bfffe5d844a-room_temperature', @@ -2822,6 +2963,7 @@ 'original_name': 'Humidity', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'gateway1_zigbee_5cc7c1fffea33a3b-room_humidity', @@ -2868,12 +3010,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'gateway1_zigbee_5cc7c1fffea33a3b-room_temperature', diff --git a/tests/components/vicare/snapshots/test_water_heater.ambr b/tests/components/vicare/snapshots/test_water_heater.ambr index 7b7ab91e086..87d98561a86 100644 --- a/tests/components/vicare/snapshots/test_water_heater.ambr +++ b/tests/components/vicare/snapshots/test_water_heater.ambr @@ -30,6 +30,7 @@ 'original_name': 'Domestic hot water', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'domestic_hot_water', 'unique_id': 'gateway0_deviceSerialVitodens300W-0', @@ -87,6 +88,7 @@ 'original_name': 'Domestic hot water', 'platform': 'vicare', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': 'domestic_hot_water', 'unique_id': 'gateway0_deviceSerialVitodens300W-1', diff --git a/tests/components/vodafone_station/snapshots/test_button.ambr b/tests/components/vodafone_station/snapshots/test_button.ambr index 736f590241a..f644da96c09 100644 --- a/tests/components/vodafone_station/snapshots/test_button.ambr +++ b/tests/components/vodafone_station/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Restart', 'platform': 'vodafone_station', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'm123456789_reboot', diff --git a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr index 7f98aad1405..f4f88c17aa6 100644 --- a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr +++ b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'original_name': 'LanDevice1', 'platform': 'vodafone_station', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_tracker', 'unique_id': 'yy:yy:yy:yy:yy:yy', @@ -78,6 +79,7 @@ 'original_name': 'WifiDevice0', 'platform': 'vodafone_station', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_tracker', 'unique_id': 'xx:xx:xx:xx:xx:xx', diff --git a/tests/components/vodafone_station/snapshots/test_sensor.ambr b/tests/components/vodafone_station/snapshots/test_sensor.ambr index 169ee92a24b..d046f1f1f0e 100644 --- a/tests/components/vodafone_station/snapshots/test_sensor.ambr +++ b/tests/components/vodafone_station/snapshots/test_sensor.ambr @@ -33,6 +33,7 @@ 'original_name': 'Active connection', 'platform': 'vodafone_station', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_connection', 'unique_id': 'm123456789_inter_ip_address', @@ -86,6 +87,7 @@ 'original_name': 'CPU usage', 'platform': 'vodafone_station', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sys_cpu_usage', 'unique_id': 'm123456789_sys_cpu_usage', @@ -134,6 +136,7 @@ 'original_name': 'Memory usage', 'platform': 'vodafone_station', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sys_memory_usage', 'unique_id': 'm123456789_sys_memory_usage', @@ -182,6 +185,7 @@ 'original_name': 'Reboot cause', 'platform': 'vodafone_station', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sys_reboot_cause', 'unique_id': 'm123456789_sys_reboot_cause', @@ -229,6 +233,7 @@ 'original_name': 'Uptime', 'platform': 'vodafone_station', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sys_uptime', 'unique_id': 'm123456789_sys_uptime', diff --git a/tests/components/vodafone_station/test_button.py b/tests/components/vodafone_station/test_button.py index ade5eb78965..84df839cae0 100644 --- a/tests/components/vodafone_station/test_button.py +++ b/tests/components/vodafone_station/test_button.py @@ -9,7 +9,7 @@ from aiovodafone.exceptions import ( GenericLoginError, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.vodafone_station.const import DOMAIN diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 7ab56f2e967..4653230f7ca 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -302,3 +302,22 @@ async def test_reconfigure_fails( assert reconfigure_result["type"] is FlowResultType.FORM assert reconfigure_result["step_id"] == "reconfigure" assert reconfigure_result["errors"] == {"base": error} + + mock_vodafone_station_router.login.side_effect = None + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.100.61", + CONF_PASSWORD: "fake_password", + CONF_USERNAME: "fake_username", + }, + ) + + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "192.168.100.61", + CONF_PASSWORD: "fake_password", + CONF_USERNAME: "fake_username", + } diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py index a94f4ad05c4..2c8c2065510 100644 --- a/tests/components/vodafone_station/test_device_tracker.py +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.vodafone_station.const import SCAN_INTERVAL from homeassistant.components.vodafone_station.coordinator import CONSIDER_HOME_SECONDS diff --git a/tests/components/vodafone_station/test_diagnostics.py b/tests/components/vodafone_station/test_diagnostics.py index 5a4a46ce693..fa74292bcbc 100644 --- a/tests/components/vodafone_station/test_diagnostics.py +++ b/tests/components/vodafone_station/test_diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/vodafone_station/test_sensor.py b/tests/components/vodafone_station/test_sensor.py index 5f27b67e3dd..35c486a359f 100644 --- a/tests/components/vodafone_station/test_sensor.py +++ b/tests/components/vodafone_station/test_sensor.py @@ -6,7 +6,7 @@ from aiovodafone import CannotAuthenticate from aiovodafone.exceptions import AlreadyLogged, CannotConnect from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.vodafone_station.const import LINE_TYPES, SCAN_INTERVAL from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 65567c8e1d1..364c4d3dd5a 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -1119,9 +1119,10 @@ async def test_start_conversation_user_doesnt_pick_up( & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION ) - # Protocol has already been mocked, but "outgoing_call" is not async + # Protocol has already been mocked, but "outgoing_call" and "cancel_call" are not async mock_protocol: AsyncMock = hass.data[DOMAIN].protocol mock_protocol.outgoing_call = Mock() + mock_protocol.cancel_call = Mock() announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index 9ec10dc72aa..d347777f7e8 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -2,6 +2,7 @@ from http import HTTPStatus +import requests import requests_mock from homeassistant.components.wallbox.const import ( @@ -12,6 +13,9 @@ from homeassistant.components.wallbox.const import ( CHARGER_CURRENCY_KEY, CHARGER_CURRENT_VERSION_KEY, CHARGER_DATA_KEY, + CHARGER_ECO_SMART_KEY, + CHARGER_ECO_SMART_MODE_KEY, + CHARGER_ECO_SMART_STATUS_KEY, CHARGER_ENERGY_PRICE_KEY, CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, @@ -50,6 +54,10 @@ test_response = { CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, CHARGER_MAX_ICP_CURRENT_KEY: 20, CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, }, } @@ -71,9 +79,89 @@ test_response_bidir = { CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, CHARGER_MAX_ICP_CURRENT_KEY: 20, CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, }, } +test_response_eco_mode = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + + +test_response_full_solar = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 1, + }, + }, +} + +test_response_no_power_boost = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []}, + }, +} + + +http_404_error = requests.exceptions.HTTPError() +http_404_error.response = requests.Response() +http_404_error.response.status_code = HTTPStatus.NOT_FOUND authorisation_response = { "data": { @@ -128,6 +216,31 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None await hass.async_block_till_done() +async def setup_integration_select( + hass: HomeAssistant, entry: MockConfigEntry, response +) -> None: + """Test wallbox sensor class setup.""" + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=HTTPStatus.OK, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=response, + status_code=HTTPStatus.OK, + ) + mock_request.put( + "https://api.wall-box.com/v2/charger/12345", + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, + status_code=HTTPStatus.OK, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + async def setup_integration_bidir(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test wallbox sensor class setup.""" with requests_mock.Mocker() as mock_request: diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index a86ae9fc3b9..82c9e5169d5 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -15,3 +15,4 @@ MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.wallbox_wallboxname_charging_speed" MOCK_SENSOR_CHARGING_POWER_ID = "sensor.wallbox_wallboxname_charging_power" MOCK_SENSOR_MAX_AVAILABLE_POWER = "sensor.wallbox_wallboxname_max_available_power" MOCK_SWITCH_ENTITY_ID = "switch.wallbox_wallboxname_pause_resume" +MOCK_SELECT_ENTITY_ID = "select.wallbox_wallboxname_solar_charging" diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py new file mode 100644 index 00000000000..516b1e87c27 --- /dev/null +++ b/tests/components/wallbox/test_select.py @@ -0,0 +1,122 @@ +"""Test Wallbox Select component.""" + +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY, EcoSmartMode +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, HomeAssistantError + +from . import ( + authorisation_response, + http_404_error, + setup_integration_select, + test_response, + test_response_eco_mode, + test_response_full_solar, + test_response_no_power_boost, +) +from .const import MOCK_SELECT_ENTITY_ID + +from tests.common import MockConfigEntry + +TEST_OPTIONS = [ + (EcoSmartMode.OFF, test_response), + (EcoSmartMode.ECO_MODE, test_response_eco_mode), + (EcoSmartMode.FULL_SOLAR, test_response_full_solar), +] + + +@pytest.fixture +def mock_authenticate(): + """Fixture to patch Wallbox methods.""" + with patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ): + yield + + +@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) +async def test_wallbox_select_solar_charging_class( + hass: HomeAssistant, entry: MockConfigEntry, mode, response, mock_authenticate +) -> None: + """Test wallbox select class.""" + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.enableEcoSmart", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + patch( + "homeassistant.components.wallbox.Wallbox.disableEcoSmart", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + ): + await setup_integration_select(hass, entry, response) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + state = hass.states.get(MOCK_SELECT_ENTITY_ID) + assert state.state == mode + + +async def test_wallbox_select_no_power_boost_class( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox select class.""" + + await setup_integration_select(hass, entry, test_response_no_power_boost) + + state = hass.states.get(MOCK_SELECT_ENTITY_ID) + assert state is None + + +@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) +@pytest.mark.parametrize("error", [http_404_error, ConnectionError]) +async def test_wallbox_select_class_error( + hass: HomeAssistant, + entry: MockConfigEntry, + mode, + response, + error, + mock_authenticate, +) -> None: + """Test wallbox select class connection error.""" + + await setup_integration_select(hass, entry, response) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.disableEcoSmart", + new=Mock(side_effect=error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.enableEcoSmart", + new=Mock(side_effect=error), + ), + pytest.raises(HomeAssistantError, match="Error communicating with Wallbox API"), + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID, + ATTR_OPTION: mode, + }, + blocking=True, + ) diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 0cd2aa67233..7fd8e214240 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aiowaqi import WAQIAirQuality, WAQIError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.waqi.const import DOMAIN diff --git a/tests/components/watergate/snapshots/test_event.ambr b/tests/components/watergate/snapshots/test_event.ambr index 97f453697ca..a7a019cc83b 100644 --- a/tests/components/watergate/snapshots/test_event.ambr +++ b/tests/components/watergate/snapshots/test_event.ambr @@ -31,6 +31,7 @@ 'original_name': 'Duration auto shut-off', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_shut_off_duration', 'unique_id': 'a63182948ce2896a.auto_shut_off_duration', @@ -86,6 +87,7 @@ 'original_name': 'Volume auto shut-off', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'auto_shut_off_volume', 'unique_id': 'a63182948ce2896a.auto_shut_off_volume', diff --git a/tests/components/watergate/snapshots/test_sensor.ambr b/tests/components/watergate/snapshots/test_sensor.ambr index b4b6c4ee0a4..9ba7bbd3024 100644 --- a/tests/components/watergate/snapshots/test_sensor.ambr +++ b/tests/components/watergate/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'MQTT up since', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mqtt_up_since', 'unique_id': 'a63182948ce2896a.mqtt_up_since', @@ -81,6 +82,7 @@ 'original_name': 'Power supply mode', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_supply_mode', 'unique_id': 'a63182948ce2896a.power_supply_mode', @@ -136,6 +138,7 @@ 'original_name': 'Signal strength', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a63182948ce2896a.rssi', @@ -186,6 +189,7 @@ 'original_name': 'Up since', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'up_since', 'unique_id': 'a63182948ce2896a.up_since', @@ -230,12 +234,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Volume flow rate', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'a63182948ce2896a.water_flow_rate', @@ -282,12 +290,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water meter duration', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_meter_duration', 'unique_id': 'a63182948ce2896a.water_meter_duration', @@ -334,12 +346,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water meter volume', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_meter_volume', 'unique_id': 'a63182948ce2896a.water_meter_volume', @@ -386,12 +402,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water pressure', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_pressure', 'unique_id': 'a63182948ce2896a.water_pressure', @@ -438,12 +458,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Water temperature', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_temperature', 'unique_id': 'a63182948ce2896a.water_temperature', @@ -494,6 +518,7 @@ 'original_name': 'Wi-Fi up since', 'platform': 'watergate', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wifi_up_since', 'unique_id': 'a63182948ce2896a.wifi_up_since', diff --git a/tests/components/watttime/test_diagnostics.py b/tests/components/watttime/test_diagnostics.py index f4465a44d26..ff697d5119e 100644 --- a/tests/components/watttime/test_diagnostics.py +++ b/tests/components/watttime/test_diagnostics.py @@ -1,6 +1,6 @@ """Test WattTime diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr index c06229302c5..f9819f39dca 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr @@ -32,6 +32,7 @@ 'original_name': 'Air density', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_density', 'unique_id': '24432_air_density', @@ -87,6 +88,7 @@ 'original_name': 'Dew point', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dew_point', 'unique_id': '24432_dew_point', @@ -143,6 +145,7 @@ 'original_name': 'Feels like', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'feels_like', 'unique_id': '24432_feels_like', @@ -199,6 +202,7 @@ 'original_name': 'Heat index', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heat_index', 'unique_id': '24432_heat_index', @@ -252,6 +256,7 @@ 'original_name': 'Lightning count', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lightning_strike_count', 'unique_id': '24432_lightning_strike_count', @@ -303,6 +308,7 @@ 'original_name': 'Lightning count last 1 hr', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lightning_strike_count_last_1hr', 'unique_id': '24432_lightning_strike_count_last_1hr', @@ -354,6 +360,7 @@ 'original_name': 'Lightning count last 3 hr', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lightning_strike_count_last_3hr', 'unique_id': '24432_lightning_strike_count_last_3hr', @@ -399,12 +406,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Lightning last distance', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lightning_strike_last_distance', 'unique_id': '24432_lightning_strike_last_distance', @@ -456,6 +467,7 @@ 'original_name': 'Lightning last strike', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'lightning_strike_last_epoch', 'unique_id': '24432_lightning_strike_last_epoch', @@ -513,6 +525,7 @@ 'original_name': 'Pressure barometric', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'barometric_pressure', 'unique_id': '24432_barometric_pressure', @@ -572,6 +585,7 @@ 'original_name': 'Pressure sea level', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sea_level_pressure', 'unique_id': '24432_sea_level_pressure', @@ -628,6 +642,7 @@ 'original_name': 'Temperature', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_temperature', 'unique_id': '24432_air_temperature', @@ -684,6 +699,7 @@ 'original_name': 'Wet bulb globe temperature', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wet_bulb_globe_temperature', 'unique_id': '24432_wet_bulb_globe_temperature', @@ -740,6 +756,7 @@ 'original_name': 'Wet bulb temperature', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wet_bulb_temperature', 'unique_id': '24432_wet_bulb_temperature', @@ -796,6 +813,7 @@ 'original_name': 'Wind chill', 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_chill', 'unique_id': '24432_wind_chill', diff --git a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr index 0b0d66c34a7..867f7874ed3 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'weatherflow_cloud', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'weatherflow_forecast_24432', diff --git a/tests/components/weatherflow_cloud/test_sensor.py b/tests/components/weatherflow_cloud/test_sensor.py index 4d6ff0c8c9f..13ac3910571 100644 --- a/tests/components/weatherflow_cloud/test_sensor.py +++ b/tests/components/weatherflow_cloud/test_sensor.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from weatherflow4py.models.rest.observation import ObservationStationREST from homeassistant.components.weatherflow_cloud import DOMAIN diff --git a/tests/components/weatherflow_cloud/test_weather.py b/tests/components/weatherflow_cloud/test_weather.py index 04da96df423..8da67b27060 100644 --- a/tests/components/weatherflow_cloud/test_weather.py +++ b/tests/components/weatherflow_cloud/test_weather.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index ca20467484f..65badabe593 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -86,14 +86,16 @@ async def test_agents_list_backups( } }, "backup_id": "23e64aec", - "date": "2025-02-10T17:47:22.727189+01:00", "database_included": True, + "date": "2025-02-10T17:47:22.727189+01:00", "extra_metadata": {}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.2.1", "name": "Automatic backup 2025.2.1", - "failed_agent_ids": [], "with_automatic_settings": None, } ] @@ -122,14 +124,16 @@ async def test_agents_get_backup( } }, "backup_id": "23e64aec", - "date": "2025-02-10T17:47:22.727189+01:00", "database_included": True, + "date": "2025-02-10T17:47:22.727189+01:00", "extra_metadata": {}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.2.1", "name": "Automatic backup 2025.2.1", - "failed_agent_ids": [], "with_automatic_settings": None, } diff --git a/tests/components/webmin/snapshots/test_sensor.ambr b/tests/components/webmin/snapshots/test_sensor.ambr index 1af5fe46b5c..6352c2bcf61 100644 --- a/tests/components/webmin/snapshots/test_sensor.ambr +++ b/tests/components/webmin/snapshots/test_sensor.ambr @@ -29,6 +29,7 @@ 'original_name': 'Disk free inodes /', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_ifree', 'unique_id': '12:34:56:78:9a:bc_/_ifree', @@ -79,6 +80,7 @@ 'original_name': 'Disk free inodes /media/disk1', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_ifree', 'unique_id': '12:34:56:78:9a:bc_/media/disk1_ifree', @@ -129,6 +131,7 @@ 'original_name': 'Disk free inodes /media/disk2', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_ifree', 'unique_id': '12:34:56:78:9a:bc_/media/disk2_ifree', @@ -185,6 +188,7 @@ 'original_name': 'Disk free space /', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_free', 'unique_id': '12:34:56:78:9a:bc_/_free', @@ -243,6 +247,7 @@ 'original_name': 'Disk free space /media/disk1', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_free', 'unique_id': '12:34:56:78:9a:bc_/media/disk1_free', @@ -301,6 +306,7 @@ 'original_name': 'Disk free space /media/disk2', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_free', 'unique_id': '12:34:56:78:9a:bc_/media/disk2_free', @@ -353,6 +359,7 @@ 'original_name': 'Disk inode usage /', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_iused_percent', 'unique_id': '12:34:56:78:9a:bc_/_iused_percent', @@ -404,6 +411,7 @@ 'original_name': 'Disk inode usage /media/disk1', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_iused_percent', 'unique_id': '12:34:56:78:9a:bc_/media/disk1_iused_percent', @@ -455,6 +463,7 @@ 'original_name': 'Disk inode usage /media/disk2', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_iused_percent', 'unique_id': '12:34:56:78:9a:bc_/media/disk2_iused_percent', @@ -506,6 +515,7 @@ 'original_name': 'Disk total inodes /', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_itotal', 'unique_id': '12:34:56:78:9a:bc_/_itotal', @@ -556,6 +566,7 @@ 'original_name': 'Disk total inodes /media/disk1', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_itotal', 'unique_id': '12:34:56:78:9a:bc_/media/disk1_itotal', @@ -606,6 +617,7 @@ 'original_name': 'Disk total inodes /media/disk2', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_itotal', 'unique_id': '12:34:56:78:9a:bc_/media/disk2_itotal', @@ -662,6 +674,7 @@ 'original_name': 'Disk total space /', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_total', 'unique_id': '12:34:56:78:9a:bc_/_total', @@ -720,6 +733,7 @@ 'original_name': 'Disk total space /media/disk1', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_total', 'unique_id': '12:34:56:78:9a:bc_/media/disk1_total', @@ -778,6 +792,7 @@ 'original_name': 'Disk total space /media/disk2', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_total', 'unique_id': '12:34:56:78:9a:bc_/media/disk2_total', @@ -830,6 +845,7 @@ 'original_name': 'Disk usage /', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_used_percent', 'unique_id': '12:34:56:78:9a:bc_/_used_percent', @@ -881,6 +897,7 @@ 'original_name': 'Disk usage /media/disk1', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_used_percent', 'unique_id': '12:34:56:78:9a:bc_/media/disk1_used_percent', @@ -932,6 +949,7 @@ 'original_name': 'Disk usage /media/disk2', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_used_percent', 'unique_id': '12:34:56:78:9a:bc_/media/disk2_used_percent', @@ -983,6 +1001,7 @@ 'original_name': 'Disk used inodes /', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_iused', 'unique_id': '12:34:56:78:9a:bc_/_iused', @@ -1033,6 +1052,7 @@ 'original_name': 'Disk used inodes /media/disk1', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_iused', 'unique_id': '12:34:56:78:9a:bc_/media/disk1_iused', @@ -1083,6 +1103,7 @@ 'original_name': 'Disk used inodes /media/disk2', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_iused', 'unique_id': '12:34:56:78:9a:bc_/media/disk2_iused', @@ -1139,6 +1160,7 @@ 'original_name': 'Disk used space /', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_used', 'unique_id': '12:34:56:78:9a:bc_/_used', @@ -1197,6 +1219,7 @@ 'original_name': 'Disk used space /media/disk1', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_used', 'unique_id': '12:34:56:78:9a:bc_/media/disk1_used', @@ -1255,6 +1278,7 @@ 'original_name': 'Disk used space /media/disk2', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_fs_used', 'unique_id': '12:34:56:78:9a:bc_/media/disk2_used', @@ -1313,6 +1337,7 @@ 'original_name': 'Disks free space', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_free', 'unique_id': '12:34:56:78:9a:bc_disk_free', @@ -1371,6 +1396,7 @@ 'original_name': 'Disks total space', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_total', 'unique_id': '12:34:56:78:9a:bc_disk_total', @@ -1429,6 +1455,7 @@ 'original_name': 'Disks used space', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'disk_used', 'unique_id': '12:34:56:78:9a:bc_disk_used', @@ -1481,6 +1508,7 @@ 'original_name': 'Load (15 min)', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'load_15m', 'unique_id': '12:34:56:78:9a:bc_load_15m', @@ -1531,6 +1559,7 @@ 'original_name': 'Load (1 min)', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'load_1m', 'unique_id': '12:34:56:78:9a:bc_load_1m', @@ -1581,6 +1610,7 @@ 'original_name': 'Load (5 min)', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'load_5m', 'unique_id': '12:34:56:78:9a:bc_load_5m', @@ -1637,6 +1667,7 @@ 'original_name': 'Memory free', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mem_free', 'unique_id': '12:34:56:78:9a:bc_mem_free', @@ -1695,6 +1726,7 @@ 'original_name': 'Memory total', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'mem_total', 'unique_id': '12:34:56:78:9a:bc_mem_total', @@ -1753,6 +1785,7 @@ 'original_name': 'Swap free', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'swap_free', 'unique_id': '12:34:56:78:9a:bc_swap_free', @@ -1811,6 +1844,7 @@ 'original_name': 'Swap total', 'platform': 'webmin', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'swap_total', 'unique_id': '12:34:56:78:9a:bc_swap_total', diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 80e6b8be056..2c9cc19c84b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -514,9 +514,12 @@ async def test_call_service_schema_validation_error( @pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_call_service_error( - hass: HomeAssistant, websocket_client: MockHAClientWebSocket + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + websocket_client: MockHAClientWebSocket, ) -> None: """Test call service command with error.""" + caplog.set_level(logging.ERROR) @callback def ha_error_call(_): @@ -561,6 +564,7 @@ async def test_call_service_error( assert msg["error"]["translation_placeholders"] == {"option": "bla"} assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" + assert "Traceback" not in caplog.text await websocket_client.send_json_auto_id( { @@ -578,6 +582,7 @@ async def test_call_service_error( assert msg["error"]["translation_placeholders"] == {"option": "bla"} assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" + assert "Traceback" not in caplog.text await websocket_client.send_json_auto_id( { @@ -592,6 +597,7 @@ async def test_call_service_error( assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" assert msg["error"]["message"] == "value_error" + assert "Traceback" in caplog.text async def test_subscribe_unsubscribe_events( @@ -2529,9 +2535,8 @@ async def test_validate_config_works( "state": "paulus", }, ( - "Unexpected value for condition: 'non_existing'. Expected and, device," - " not, numeric_state, or, state, sun, template, time, trigger, zone " - "@ data[0]" + "Invalid condition \"non_existing\" specified {'condition': " + "'non_existing', 'entity_id': 'hello.world', 'state': 'paulus'}" ), ), # Raises HomeAssistantError diff --git a/tests/components/weheat/snapshots/test_binary_sensor.ambr b/tests/components/weheat/snapshots/test_binary_sensor.ambr index bdcd727fbcc..8f6f635d79e 100644 --- a/tests/components/weheat/snapshots/test_binary_sensor.ambr +++ b/tests/components/weheat/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Indoor unit auxiliary water pump', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_unit_auxiliary_pump_state', 'unique_id': '0000-1111-2222-3333_indoor_unit_auxiliary_pump_state', @@ -75,6 +76,7 @@ 'original_name': 'Indoor unit electric heater', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_unit_electric_heater_state', 'unique_id': '0000-1111-2222-3333_indoor_unit_electric_heater_state', @@ -123,6 +125,7 @@ 'original_name': 'Indoor unit gas boiler heating allowed', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_unit_gas_boiler_state', 'unique_id': '0000-1111-2222-3333_indoor_unit_gas_boiler_state', @@ -170,6 +173,7 @@ 'original_name': 'Indoor unit water pump', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_unit_water_pump_state', 'unique_id': '0000-1111-2222-3333_indoor_unit_water_pump_state', diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index b968d925675..8631f0ab6bf 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -39,6 +39,7 @@ 'original_name': None, 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heat_pump_state', 'unique_id': '0000-1111-2222-3333_heat_pump_state', @@ -103,6 +104,7 @@ 'original_name': 'Central heating inlet temperature', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ch_inlet_temperature', 'unique_id': '0000-1111-2222-3333_ch_inlet_temperature', @@ -158,6 +160,7 @@ 'original_name': 'Central heating pump flow', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'central_heating_flow_volume', 'unique_id': '0000-1111-2222-3333_central_heating_flow_volume', @@ -210,6 +213,7 @@ 'original_name': 'Compressor speed', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'compressor_rpm', 'unique_id': '0000-1111-2222-3333_compressor_rpm', @@ -261,6 +265,7 @@ 'original_name': 'Compressor usage', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'compressor_percentage', 'unique_id': '0000-1111-2222-3333_compressor_percentage', @@ -315,6 +320,7 @@ 'original_name': 'COP', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cop', 'unique_id': '0000-1111-2222-3333_cop', @@ -368,6 +374,7 @@ 'original_name': 'Current room temperature', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermostat_room_temperature', 'unique_id': '0000-1111-2222-3333_thermostat_room_temperature', @@ -423,6 +430,7 @@ 'original_name': 'DHW bottom temperature', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhw_bottom_temperature', 'unique_id': '0000-1111-2222-3333_dhw_bottom_temperature', @@ -478,6 +486,7 @@ 'original_name': 'DHW pump flow', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhw_flow_volume', 'unique_id': '0000-1111-2222-3333_dhw_flow_volume', @@ -533,6 +542,7 @@ 'original_name': 'DHW top temperature', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhw_top_temperature', 'unique_id': '0000-1111-2222-3333_dhw_top_temperature', @@ -579,12 +589,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Electricity used', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'electricity_used', 'unique_id': '0000-1111-2222-3333_electricity_used', @@ -640,6 +654,7 @@ 'original_name': 'Input power', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_input', 'unique_id': '0000-1111-2222-3333_power_input', @@ -695,6 +710,7 @@ 'original_name': 'Output power', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_output', 'unique_id': '0000-1111-2222-3333_power_output', @@ -750,6 +766,7 @@ 'original_name': 'Outside temperature', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': '0000-1111-2222-3333_outside_temperature', @@ -805,6 +822,7 @@ 'original_name': 'Room temperature setpoint', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermostat_room_temperature_setpoint', 'unique_id': '0000-1111-2222-3333_thermostat_room_temperature_setpoint', @@ -851,12 +869,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy output', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_output', 'unique_id': '0000-1111-2222-3333_energy_output', @@ -912,6 +934,7 @@ 'original_name': 'Water inlet temperature', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_inlet_temperature', 'unique_id': '0000-1111-2222-3333_water_inlet_temperature', @@ -967,6 +990,7 @@ 'original_name': 'Water outlet temperature', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_outlet_temperature', 'unique_id': '0000-1111-2222-3333_water_outlet_temperature', @@ -1022,6 +1046,7 @@ 'original_name': 'Water target temperature', 'platform': 'weheat', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'thermostat_water_setpoint', 'unique_id': '0000-1111-2222-3333_thermostat_water_setpoint', diff --git a/tests/components/weheat/test_binary_sensor.py b/tests/components/weheat/test_binary_sensor.py index 5769fc9a1a8..69122a35ea9 100644 --- a/tests/components/weheat/test_binary_sensor.py +++ b/tests/components/weheat/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery from homeassistant.const import Platform diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index eab571b09ed..b4d436cdaf1 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery from homeassistant.const import Platform diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py index 7d915b91116..ca96ff1f2a9 100644 --- a/tests/components/whirlpool/__init__.py +++ b/tests/components/whirlpool/__init__.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform diff --git a/tests/components/whirlpool/snapshots/test_binary_sensor.ambr b/tests/components/whirlpool/snapshots/test_binary_sensor.ambr index 1a902f806cf..1a0445a4803 100644 --- a/tests/components/whirlpool/snapshots/test_binary_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Door', 'platform': 'whirlpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'said_dryer-door', @@ -75,6 +76,7 @@ 'original_name': 'Door', 'platform': 'whirlpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'said_washer-door', diff --git a/tests/components/whirlpool/snapshots/test_climate.ambr b/tests/components/whirlpool/snapshots/test_climate.ambr index 2957a609fa2..58b894d07cb 100644 --- a/tests/components/whirlpool/snapshots/test_climate.ambr +++ b/tests/components/whirlpool/snapshots/test_climate.ambr @@ -48,6 +48,7 @@ 'original_name': None, 'platform': 'whirlpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'said1', @@ -142,6 +143,7 @@ 'original_name': None, 'platform': 'whirlpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'said2', diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index 6a0465ba8b9..843e71b62ea 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'End time', 'platform': 'whirlpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'end_time', 'unique_id': 'said_dryer-timeremaining', @@ -105,6 +106,7 @@ 'original_name': 'State', 'platform': 'whirlpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dryer_state', 'unique_id': 'said_dryer-state', @@ -189,6 +191,7 @@ 'original_name': 'Detergent level', 'platform': 'whirlpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'whirlpool_tank', 'unique_id': 'said_washer-DispenseLevel', @@ -244,6 +247,7 @@ 'original_name': 'End time', 'platform': 'whirlpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'end_time', 'unique_id': 'said_washer-timeremaining', @@ -322,6 +326,7 @@ 'original_name': 'State', 'platform': 'whirlpool', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'washer_state', 'unique_id': 'said_washer-state', diff --git a/tests/components/whirlpool/test_binary_sensor.py b/tests/components/whirlpool/test_binary_sensor.py index bdd4c05c05d..e4539fa5d13 100644 --- a/tests/components/whirlpool/test_binary_sensor.py +++ b/tests/components/whirlpool/test_binary_sensor.py @@ -1,7 +1,7 @@ """Test the Whirlpool Binary Sensor domain.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index e9fb47d1c28..2c36c713546 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion import whirlpool from homeassistant.components.climate import ( diff --git a/tests/components/whirlpool/test_diagnostics.py b/tests/components/whirlpool/test_diagnostics.py index 192339156e1..6ffdc82289f 100644 --- a/tests/components/whirlpool/test_diagnostics.py +++ b/tests/components/whirlpool/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Blink diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 9aa88c26123..6e28539d661 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from whirlpool.washerdryer import MachineState from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL diff --git a/tests/components/whois/conftest.py b/tests/components/whois/conftest.py index 4bb18581c1a..c4138a5d1d2 100644 --- a/tests/components/whois/conftest.py +++ b/tests/components/whois/conftest.py @@ -63,7 +63,7 @@ def mock_whois() -> Generator[MagicMock]: domain.registrant = "registrant@example.com" domain.registrar = "My Registrar" domain.reseller = "Top Domains, Low Prices" - domain.status = "OK" + domain.status = "ok" domain.statuses = ["OK"] yield whois_mock @@ -86,7 +86,7 @@ def mock_whois_missing_some_attrs() -> Generator[Mock]: self.name = "home-assistant.io" self.name_servers = ["ns1.example.com", "ns2.example.com"] self.registrar = "My Registrar" - self.status = "OK" + self.status = "ok" self.statuses = ["OK"] with patch( diff --git a/tests/components/whois/snapshots/test_diagnostics.ambr b/tests/components/whois/snapshots/test_diagnostics.ambr index f373a20700e..a498d0f88e9 100644 --- a/tests/components/whois/snapshots/test_diagnostics.ambr +++ b/tests/components/whois/snapshots/test_diagnostics.ambr @@ -5,7 +5,7 @@ 'dnssec': True, 'expiration_date': '2023-01-01T00:00:00', 'last_updated': '2022-01-01T00:00:00+01:00', - 'status': 'OK', + 'status': 'ok', 'statuses': list([ 'OK', ]), diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index b5b1dde1c3d..67f6baf45bb 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -40,6 +40,7 @@ 'original_name': 'Admin', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'admin', 'unique_id': 'home-assistant.io_admin', @@ -121,6 +122,7 @@ 'original_name': 'Created', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'creation_date', 'unique_id': 'home-assistant.io_creation_date', @@ -206,6 +208,7 @@ 'original_name': 'Days until expiration', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'days_until_expiration', 'unique_id': 'home-assistant.io_days_until_expiration', @@ -287,6 +290,7 @@ 'original_name': 'Expires', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'expiration_date', 'unique_id': 'home-assistant.io_expiration_date', @@ -368,6 +372,7 @@ 'original_name': 'Last updated', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_updated', 'unique_id': 'home-assistant.io_last_updated', @@ -448,6 +453,7 @@ 'original_name': 'Owner', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'owner', 'unique_id': 'home-assistant.io_owner', @@ -528,6 +534,7 @@ 'original_name': 'Registrant', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'registrant', 'unique_id': 'home-assistant.io_registrant', @@ -608,6 +615,7 @@ 'original_name': 'Registrar', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'registrar', 'unique_id': 'home-assistant.io_registrar', @@ -688,6 +696,7 @@ 'original_name': 'Reseller', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reseller', 'unique_id': 'home-assistant.io_reseller', @@ -727,6 +736,139 @@ 'via_device_id': None, }) # --- +# name: test_whois_sensors[sensor.home_assistant_io_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'home-assistant.io Status', + 'options': list([ + 'add_period', + 'auto_renew_period', + 'inactive', + 'active', + 'pending_create', + 'pending_renew', + 'pending_restore', + 'pending_transfer', + 'pending_update', + 'redemption_period', + 'renew_period', + 'server_delete_prohibited', + 'server_hold', + 'server_renew_prohibited', + 'server_transfer_prohibited', + 'server_update_prohibited', + 'transfer_period', + 'client_delete_prohibited', + 'client_hold', + 'client_renew_prohibited', + 'client_transfer_prohibited', + 'client_update_prohibited', + 'ok', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_status].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'add_period', + 'auto_renew_period', + 'inactive', + 'active', + 'pending_create', + 'pending_renew', + 'pending_restore', + 'pending_transfer', + 'pending_update', + 'redemption_period', + 'renew_period', + 'server_delete_prohibited', + 'server_hold', + 'server_renew_prohibited', + 'server_transfer_prohibited', + 'server_update_prohibited', + 'transfer_period', + 'client_delete_prohibited', + 'client_hold', + 'client_renew_prohibited', + 'client_transfer_prohibited', + 'client_update_prohibited', + 'ok', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'whois', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'home-assistant.io_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_status].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_whois_sensors_missing_some_attrs StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -769,6 +911,7 @@ 'original_name': 'Last updated', 'platform': 'whois', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_updated', 'unique_id': 'home-assistant.io_last_updated', diff --git a/tests/components/whois/test_sensor.py b/tests/components/whois/test_sensor.py index d290bc347a9..69e32d923c4 100644 --- a/tests/components/whois/test_sensor.py +++ b/tests/components/whois/test_sensor.py @@ -32,6 +32,7 @@ pytestmark = [ "sensor.home_assistant_io_registrant", "sensor.home_assistant_io_registrar", "sensor.home_assistant_io_reseller", + "sensor.home_assistant_io_status", ], ) async def test_whois_sensors( @@ -73,6 +74,7 @@ async def test_whois_sensors_missing_some_attrs( "sensor.home_assistant_io_registrant", "sensor.home_assistant_io_registrar", "sensor.home_assistant_io_reseller", + "sensor.home_assistant_io_status", ], ) async def test_disabled_by_default_sensors( @@ -98,6 +100,7 @@ async def test_disabled_by_default_sensors( "sensor.home_assistant_io_registrant", "sensor.home_assistant_io_registrar", "sensor.home_assistant_io_reseller", + "sensor.home_assistant_io_status", ], ) async def test_no_data( diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index f735c506f65..446956c12a8 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -33,6 +33,7 @@ 'original_name': 'Battery', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', 'unique_id': 'f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d_battery', @@ -91,6 +92,7 @@ 'original_name': 'Active calories burnt today', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_active_calories_burnt_today', 'unique_id': 'withings_12345_activity_active_calories_burnt_today', @@ -137,6 +139,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -146,6 +151,7 @@ 'original_name': 'Active time today', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_active_duration_today', 'unique_id': 'withings_12345_activity_active_duration_today', @@ -166,7 +172,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.530', + 'state': '0.529722222222222', }) # --- # name: test_all_entities[sensor.henk_average_heart_rate-entry] @@ -199,6 +205,7 @@ 'original_name': 'Average heart rate', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'average_heart_rate', 'unique_id': 'withings_12345_sleep_heart_rate_average_bpm', @@ -250,6 +257,7 @@ 'original_name': 'Average respiratory rate', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'average_respiratory_rate', 'unique_id': 'withings_12345_sleep_respiratory_average_bpm', @@ -295,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Body temperature', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'body_temperature', 'unique_id': 'withings_12345_body_temperature_c', @@ -356,6 +368,7 @@ 'original_name': 'Bone mass', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bone_mass', 'unique_id': 'withings_12345_bone_mass_kg', @@ -408,6 +421,7 @@ 'original_name': 'Breathing disturbances intensity', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'breathing_disturbances_intensity', 'unique_id': 'withings_12345_sleep_breathing_disturbances_intensity', @@ -459,6 +473,7 @@ 'original_name': 'Calories burnt last workout', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'workout_active_calories_burnt', 'unique_id': 'withings_12345_workout_active_calories_burnt', @@ -503,6 +518,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -512,6 +530,7 @@ 'original_name': 'Deep sleep', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'deep_sleep', 'unique_id': 'withings_12345_sleep_deep_duration_seconds', @@ -531,7 +550,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.617', + 'state': '1.61666666666667', }) # --- # name: test_all_entities[sensor.henk_diastolic_blood_pressure-entry] @@ -564,6 +583,7 @@ 'original_name': 'Diastolic blood pressure', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'diastolic_blood_pressure', 'unique_id': 'withings_12345_diastolic_blood_pressure_mmhg', @@ -616,6 +636,7 @@ 'original_name': 'Distance travelled last workout', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'workout_distance', 'unique_id': 'withings_12345_workout_distance', @@ -670,6 +691,7 @@ 'original_name': 'Distance travelled today', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_distance_today', 'unique_id': 'withings_12345_activity_distance_today', @@ -721,6 +743,7 @@ 'original_name': 'Electrodermal activity feet', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'electrodermal_activity_feet', 'unique_id': 'withings_12345_electrodermal_activity_feet', @@ -769,6 +792,7 @@ 'original_name': 'Electrodermal activity left foot', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'electrodermal_activity_left_foot', 'unique_id': 'withings_12345_electrodermal_activity_left_foot', @@ -817,6 +841,7 @@ 'original_name': 'Electrodermal activity right foot', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'electrodermal_activity_right_foot', 'unique_id': 'withings_12345_electrodermal_activity_right_foot', @@ -859,12 +884,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Elevation change last workout', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'workout_elevation', 'unique_id': 'withings_12345_workout_floors_climbed', @@ -910,12 +939,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Elevation change today', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_elevation_today', 'unique_id': 'withings_12345_activity_floors_climbed_today', @@ -963,12 +996,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Extracellular water', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'extracellular_water', 'unique_id': 'withings_12345_extracellular_water', @@ -1024,6 +1061,7 @@ 'original_name': 'Fat free mass', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_free_mass', 'unique_id': 'withings_12345_fat_free_mass_kg', @@ -1079,6 +1117,7 @@ 'original_name': 'Fat free mass in left arm', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_free_mass_for_segments_left_arm', 'unique_id': 'withings_12345_fat_free_mass_for_segments_left_arm', @@ -1134,6 +1173,7 @@ 'original_name': 'Fat free mass in left leg', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_free_mass_for_segments_left_leg', 'unique_id': 'withings_12345_fat_free_mass_for_segments_left_leg', @@ -1189,6 +1229,7 @@ 'original_name': 'Fat free mass in right arm', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_free_mass_for_segments_right_arm', 'unique_id': 'withings_12345_fat_free_mass_for_segments_right_arm', @@ -1244,6 +1285,7 @@ 'original_name': 'Fat free mass in right leg', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_free_mass_for_segments_right_leg', 'unique_id': 'withings_12345_fat_free_mass_for_segments_right_leg', @@ -1299,6 +1341,7 @@ 'original_name': 'Fat free mass in torso', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_free_mass_for_segments_torso', 'unique_id': 'withings_12345_fat_free_mass_for_segments_torso', @@ -1354,6 +1397,7 @@ 'original_name': 'Fat mass', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_mass', 'unique_id': 'withings_12345_fat_mass_kg', @@ -1409,6 +1453,7 @@ 'original_name': 'Fat mass in left arm', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_mass_for_segments_left_arm', 'unique_id': 'withings_12345_fat_mass_for_segments_left_arm', @@ -1464,6 +1509,7 @@ 'original_name': 'Fat mass in left leg', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_mass_for_segments_left_leg', 'unique_id': 'withings_12345_fat_mass_for_segments_left_leg', @@ -1519,6 +1565,7 @@ 'original_name': 'Fat mass in right arm', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_mass_for_segments_right_arm', 'unique_id': 'withings_12345_fat_mass_for_segments_right_arm', @@ -1574,6 +1621,7 @@ 'original_name': 'Fat mass in right leg', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_mass_for_segments_right_leg', 'unique_id': 'withings_12345_fat_mass_for_segments_right_leg', @@ -1629,6 +1677,7 @@ 'original_name': 'Fat mass in torso', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_mass_for_segments_torso', 'unique_id': 'withings_12345_fat_mass_for_segments_torso', @@ -1684,6 +1733,7 @@ 'original_name': 'Fat ratio', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'fat_ratio', 'unique_id': 'withings_12345_fat_ratio_pct', @@ -1735,6 +1785,7 @@ 'original_name': 'Heart pulse', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'heart_pulse', 'unique_id': 'withings_12345_heart_pulse_bpm', @@ -1789,6 +1840,7 @@ 'original_name': 'Height', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'height', 'unique_id': 'withings_12345_height_m', @@ -1835,12 +1887,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Hydration', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'hydration', 'unique_id': 'withings_12345_hydration', @@ -1887,6 +1943,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -1896,6 +1955,7 @@ 'original_name': 'Intense activity today', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_intense_duration_today', 'unique_id': 'withings_12345_activity_intense_duration_today', @@ -1943,12 +2003,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Intracellular water', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'intracellular_water', 'unique_id': 'withings_12345_intracellular_water', @@ -1993,6 +2057,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -2002,6 +2069,7 @@ 'original_name': 'Last workout duration', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'workout_duration', 'unique_id': 'withings_12345_workout_duration', @@ -2051,6 +2119,7 @@ 'original_name': 'Last workout intensity', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'workout_intensity', 'unique_id': 'withings_12345_workout_intensity', @@ -2150,6 +2219,7 @@ 'original_name': 'Last workout type', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'workout_type', 'unique_id': 'withings_12345_workout_type', @@ -2245,6 +2315,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -2254,6 +2327,7 @@ 'original_name': 'Light sleep', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_sleep', 'unique_id': 'withings_12345_sleep_light_duration_seconds', @@ -2273,7 +2347,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.900', + 'state': '2.9', }) # --- # name: test_all_entities[sensor.henk_maximum_heart_rate-entry] @@ -2306,6 +2380,7 @@ 'original_name': 'Maximum heart rate', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'maximum_heart_rate', 'unique_id': 'withings_12345_sleep_heart_rate_max_bpm', @@ -2357,6 +2432,7 @@ 'original_name': 'Maximum respiratory rate', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'maximum_respiratory_rate', 'unique_id': 'withings_12345_sleep_respiratory_max_bpm', @@ -2408,6 +2484,7 @@ 'original_name': 'Minimum heart rate', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'minimum_heart_rate', 'unique_id': 'withings_12345_sleep_heart_rate_min_bpm', @@ -2459,6 +2536,7 @@ 'original_name': 'Minimum respiratory rate', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'minimum_respiratory_rate', 'unique_id': 'withings_12345_sleep_respiratory_min_bpm', @@ -2504,6 +2582,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -2513,6 +2594,7 @@ 'original_name': 'Moderate activity today', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_moderate_duration_today', 'unique_id': 'withings_12345_activity_moderate_duration_today', @@ -2533,7 +2615,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '24.8', + 'state': '24.7833333333333', }) # --- # name: test_all_entities[sensor.henk_muscle_mass-entry] @@ -2569,6 +2651,7 @@ 'original_name': 'Muscle mass', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'muscle_mass', 'unique_id': 'withings_12345_muscle_mass_kg', @@ -2624,6 +2707,7 @@ 'original_name': 'Muscle mass in left arm', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'muscle_mass_for_segments_left_arm', 'unique_id': 'withings_12345_muscle_mass_for_segments_left_arm', @@ -2679,6 +2763,7 @@ 'original_name': 'Muscle mass in left leg', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'muscle_mass_for_segments_left_leg', 'unique_id': 'withings_12345_muscle_mass_for_segments_left_leg', @@ -2734,6 +2819,7 @@ 'original_name': 'Muscle mass in right arm', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'muscle_mass_for_segments_right_arm', 'unique_id': 'withings_12345_muscle_mass_for_segments_right_arm', @@ -2789,6 +2875,7 @@ 'original_name': 'Muscle mass in right leg', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'muscle_mass_for_segments_right_leg', 'unique_id': 'withings_12345_muscle_mass_for_segments_right_leg', @@ -2844,6 +2931,7 @@ 'original_name': 'Muscle mass in torso', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'muscle_mass_for_segments_torso', 'unique_id': 'withings_12345_muscle_mass_for_segments_torso', @@ -2888,6 +2976,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -2897,6 +2988,7 @@ 'original_name': 'Pause during last workout', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'workout_pause_duration', 'unique_id': 'withings_12345_workout_pause_duration', @@ -2942,12 +3034,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pulse wave velocity', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pulse_wave_velocity', 'unique_id': 'withings_12345_pulse_wave_velocity', @@ -2994,6 +3090,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3003,6 +3102,7 @@ 'original_name': 'REM sleep', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rem_sleep', 'unique_id': 'withings_12345_sleep_rem_duration_seconds', @@ -3022,7 +3122,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.667', + 'state': '0.666666666666667', }) # --- # name: test_all_entities[sensor.henk_skin_temperature-entry] @@ -3049,12 +3149,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Skin temperature', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'skin_temperature', 'unique_id': 'withings_12345_skin_temperature_c', @@ -3101,6 +3205,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3110,6 +3217,7 @@ 'original_name': 'Sleep goal', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sleep_goal', 'unique_id': 'withings_12345_sleep_goal', @@ -3129,7 +3237,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '8.000', + 'state': '8.0', }) # --- # name: test_all_entities[sensor.henk_sleep_score-entry] @@ -3162,6 +3270,7 @@ 'original_name': 'Sleep score', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sleep_score', 'unique_id': 'withings_12345_sleep_score', @@ -3207,6 +3316,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3216,6 +3328,7 @@ 'original_name': 'Snoring', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'snoring', 'unique_id': 'withings_12345_sleep_snoring', @@ -3268,6 +3381,7 @@ 'original_name': 'Snoring episode count', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'snoring_episode_count', 'unique_id': 'withings_12345_sleep_snoring_eposode_count', @@ -3312,6 +3426,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3321,6 +3438,7 @@ 'original_name': 'Soft activity today', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_soft_duration_today', 'unique_id': 'withings_12345_activity_soft_duration_today', @@ -3341,7 +3459,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '25.3', + 'state': '25.2666666666667', }) # --- # name: test_all_entities[sensor.henk_spo2-entry] @@ -3374,6 +3492,7 @@ 'original_name': 'SpO2', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'spo2', 'unique_id': 'withings_12345_spo2_pct', @@ -3425,6 +3544,7 @@ 'original_name': 'Step goal', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'step_goal', 'unique_id': 'withings_12345_step_goal', @@ -3476,6 +3596,7 @@ 'original_name': 'Steps today', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_steps_today', 'unique_id': 'withings_12345_activity_steps_today', @@ -3528,6 +3649,7 @@ 'original_name': 'Systolic blood pressure', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'systolic_blood_pressure', 'unique_id': 'withings_12345_systolic_blood_pressure_mmhg', @@ -3573,12 +3695,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'withings_12345_temperature_c', @@ -3625,6 +3751,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3634,6 +3763,7 @@ 'original_name': 'Time to sleep', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'time_to_sleep', 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', @@ -3653,7 +3783,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.150', + 'state': '0.15', }) # --- # name: test_all_entities[sensor.henk_time_to_wakeup-entry] @@ -3680,6 +3810,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3689,6 +3822,7 @@ 'original_name': 'Time to wakeup', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'time_to_wakeup', 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', @@ -3708,7 +3842,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.317', + 'state': '0.316666666666667', }) # --- # name: test_all_entities[sensor.henk_total_calories_burnt_today-entry] @@ -3744,6 +3878,7 @@ 'original_name': 'Total calories burnt today', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'activity_total_calories_burnt_today', 'unique_id': 'withings_12345_activity_total_calories_burnt_today', @@ -3794,6 +3929,7 @@ 'original_name': 'Vascular age', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vascular_age', 'unique_id': 'withings_12345_vascular_age', @@ -3841,6 +3977,7 @@ 'original_name': 'Visceral fat index', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'visceral_fat_index', 'unique_id': 'withings_12345_visceral_fat', @@ -3890,6 +4027,7 @@ 'original_name': 'VO2 max', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vo2_max', 'unique_id': 'withings_12345_vo2_max', @@ -3941,6 +4079,7 @@ 'original_name': 'Wakeup count', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wakeup_count', 'unique_id': 'withings_12345_sleep_wakeup_count', @@ -3986,6 +4125,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , }), @@ -3995,6 +4137,7 @@ 'original_name': 'Wakeup time', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wakeup_time', 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', @@ -4014,7 +4157,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.850', + 'state': '0.85', }) # --- # name: test_all_entities[sensor.henk_weight-entry] @@ -4050,6 +4193,7 @@ 'original_name': 'Weight', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'withings_12345_weight_kg', @@ -4096,12 +4240,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Weight goal', 'platform': 'withings', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'weight_goal', 'unique_id': 'withings_12345_weight_goal', diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index b61a54150e4..4c9e2bef0d6 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -312,6 +312,15 @@ async def test_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=service_info ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "oauth_discovery" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) state = config_entry_oauth2_flow._encode_jwt( hass, { diff --git a/tests/components/withings/test_diagnostics.py b/tests/components/withings/test_diagnostics.py index 51f54b2ab17..2b58d6d22cf 100644 --- a/tests/components/withings/test_diagnostics.py +++ b/tests/components/withings/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index d88af39488b..e71402b8a98 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -15,7 +15,7 @@ from aiowithings import ( ) from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components import cloud diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 20927c197a4..0b863721f85 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from aiowithings import Goals from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.withings import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform diff --git a/tests/components/wiz/test_diagnostics.py b/tests/components/wiz/test_diagnostics.py index 07178d5e93b..14fbdbf916a 100644 --- a/tests/components/wiz/test_diagnostics.py +++ b/tests/components/wiz/test_diagnostics.py @@ -1,6 +1,6 @@ """Test WiZ diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index a22c1a3fb85..d8a29ed7c48 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -41,6 +41,7 @@ 'original_name': 'Restart', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_restart', diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index a99831d1440..877c8baa93e 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -49,6 +49,7 @@ 'original_name': 'Segment 1 intensity', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'segment_intensity', 'unique_id': 'aabbccddeeff_intensity_1', @@ -142,6 +143,7 @@ 'original_name': 'Segment 1 speed', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'segment_speed', 'unique_id': 'aabbccddeeff_speed_1', diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index d3f8fbcc21d..6cfbe1de5d4 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -51,6 +51,7 @@ 'original_name': 'Live override', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'live_override', 'unique_id': 'aabbccddeeff_live_override', @@ -282,6 +283,7 @@ 'original_name': 'Segment 1 color palette', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'segment_color_palette', 'unique_id': 'aabbccddeeff_palette_1', @@ -375,6 +377,7 @@ 'original_name': 'Playlist', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'playlist', 'unique_id': 'aabbccddeeff_playlist', @@ -468,6 +471,7 @@ 'original_name': 'Preset', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'preset', 'unique_id': 'aabbccddeeff_preset', diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index 99358153fe1..c32bc314cc0 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -42,6 +42,7 @@ 'original_name': 'Nightlight', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'nightlight', 'unique_id': 'aabbccddeeff_nightlight', @@ -126,6 +127,7 @@ 'original_name': 'Reverse', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reverse', 'unique_id': 'aabbccddeeff_reverse_0', @@ -211,6 +213,7 @@ 'original_name': 'Sync receive', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sync_receive', 'unique_id': 'aabbccddeeff_sync_receive', @@ -296,6 +299,7 @@ 'original_name': 'Sync send', 'platform': 'wled', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sync_send', 'unique_id': 'aabbccddeeff_sync_send', diff --git a/tests/components/wmspro/snapshots/test_button.ambr b/tests/components/wmspro/snapshots/test_button.ambr new file mode 100644 index 00000000000..431a92c26d6 --- /dev/null +++ b/tests/components/wmspro/snapshots/test_button.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_button_update + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by WMS WebControl pro API', + 'device_class': 'identify', + 'friendly_name': 'Markise Identify', + }), + 'context': , + 'entity_id': 'button.markise_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/wmspro/test_button.py b/tests/components/wmspro/test_button.py new file mode 100644 index 00000000000..980b347ea2b --- /dev/null +++ b/tests/components/wmspro/test_button.py @@ -0,0 +1,66 @@ +"""Test the wmspro button support.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion 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 . import setup_config_entry + +from tests.common import MockConfigEntry + + +async def test_button_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that a button 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_awning_dimmer.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) == 2 + + entity = hass.states.get("button.markise_identify") + assert entity is not None + assert entity == snapshot + + +async def test_button_press( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, +) -> None: + """Test that a button entity is pressed correctly.""" + + assert await setup_config_entry(hass, mock_config_entry) + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + entity = hass.states.get("button.markise_identify") + before_state = entity.state + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("button.markise_identify") + assert entity is not None + assert entity.state != before_state + assert len(mock_hub_status_prod_awning.mock_calls) == before diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py index ba2ab796c7d..f28d7f849ef 100644 --- a/tests/components/wmspro/test_cover.py +++ b/tests/components/wmspro/test_cover.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.wmspro.const import DOMAIN from homeassistant.components.wmspro.cover import SCAN_INTERVAL diff --git a/tests/components/wmspro/test_diagnostics.py b/tests/components/wmspro/test_diagnostics.py index 24698cfc493..43313402f78 100644 --- a/tests/components/wmspro/test_diagnostics.py +++ b/tests/components/wmspro/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/wmspro/test_init.py b/tests/components/wmspro/test_init.py index 56857ae86ca..c0fab8e2c81 100644 --- a/tests/components/wmspro/test_init.py +++ b/tests/components/wmspro/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock import aiohttp import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.wmspro.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/wmspro/test_light.py b/tests/components/wmspro/test_light.py index 9f45a821884..749c1d9104b 100644 --- a/tests/components/wmspro/test_light.py +++ b/tests/components/wmspro/test_light.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.wmspro.const import DOMAIN diff --git a/tests/components/wmspro/test_scene.py b/tests/components/wmspro/test_scene.py index a6b16e5bbc9..9a24d54fa76 100644 --- a/tests/components/wmspro/test_scene.py +++ b/tests/components/wmspro/test_scene.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.wmspro.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr index c1ff80c9630..c5b23cc8e79 100644 --- a/tests/components/wolflink/snapshots/test_sensor.ambr +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -54,12 +54,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:6005200000', @@ -106,12 +110,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Flow Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:11005200000', @@ -158,12 +166,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Frequency Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:9005200000', @@ -216,6 +228,7 @@ 'original_name': 'Hours Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:7005200000', @@ -268,6 +281,7 @@ 'original_name': 'List Item Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'state', 'unique_id': '1234:8005200000', @@ -318,6 +332,7 @@ 'original_name': 'Percentage Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:2005200000', @@ -363,12 +378,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:5005200000', @@ -415,12 +434,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Pressure Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:4005200000', @@ -475,6 +498,7 @@ 'original_name': 'RPM Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:10005200000', @@ -527,6 +551,7 @@ 'original_name': 'Simple Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:1005200000', @@ -571,12 +596,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Temperature Parameter', 'platform': 'wolflink', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1234:3005200000', diff --git a/tests/components/wolflink/test_sensor.py b/tests/components/wolflink/test_sensor.py index 8fc78f707d5..ad0325ec06e 100644 --- a/tests/components/wolflink/test_sensor.py +++ b/tests/components/wolflink/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 212c3e9d305..8f8894e3536 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -461,3 +461,49 @@ async def test_only_repairs_for_current_next_year( assert len(issue_registry.issues) == 2 assert issue_registry.issues == snapshot + + +async def test_missing_language( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when language exist but is empty.""" + config = { + "add_holidays": [], + "country": "AU", + "days_offset": 0, + "excludes": ["sat", "sun", "holiday"], + "language": None, + "name": "Workday Sensor", + "platform": "workday", + "province": "QLD", + "remove_holidays": [ + "Labour Day", + ], + "workdays": ["mon", "tue", "wed", "thu", "fri"], + } + await init_integration(hass, config) + assert "Changing language from None to en_AU" in caplog.text + + +async def test_incorrect_english_variant( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when language exist but is empty.""" + config = { + "add_holidays": [], + "country": "AU", + "days_offset": 0, + "excludes": ["sat", "sun", "holiday"], + "language": "en_UK", # Incorrect variant + "name": "Workday Sensor", + "platform": "workday", + "province": "QLD", + "remove_holidays": [ + "Labour Day", + ], + "workdays": ["mon", "tue", "wed", "thu", "fri"], + } + await init_integration(hass, config) + assert "Changing language from en_UK to en_AU" in caplog.text diff --git a/tests/components/wsdot/conftest.py b/tests/components/wsdot/conftest.py new file mode 100644 index 00000000000..48e2f0a90f7 --- /dev/null +++ b/tests/components/wsdot/conftest.py @@ -0,0 +1,24 @@ +"""Provide common WSDOT fixtures.""" + +from collections.abc import AsyncGenerator +from unittest.mock import patch + +import pytest +from wsdot import TravelTime + +from homeassistant.components.wsdot.sensor import DOMAIN + +from tests.common import load_json_object_fixture + + +@pytest.fixture +def mock_travel_time() -> AsyncGenerator[TravelTime]: + """WsdotTravelTimes.get_travel_time is mocked to return a TravelTime data based on test fixture payload.""" + with patch( + "homeassistant.components.wsdot.sensor.WsdotTravelTimes", autospec=True + ) as mock: + client = mock.return_value + client.get_travel_time.return_value = TravelTime( + **load_json_object_fixture("wsdot.json", DOMAIN) + ) + yield mock diff --git a/tests/components/wsdot/test_sensor.py b/tests/components/wsdot/test_sensor.py index ff3d4960735..60d28991b56 100644 --- a/tests/components/wsdot/test_sensor.py +++ b/tests/components/wsdot/test_sensor.py @@ -1,64 +1,41 @@ """The tests for the WSDOT platform.""" from datetime import datetime, timedelta, timezone -import re +from unittest.mock import AsyncMock -import requests_mock - -from homeassistant.components.wsdot import sensor as wsdot from homeassistant.components.wsdot.sensor import ( - ATTR_DESCRIPTION, - ATTR_TIME_UPDATED, CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TRAVEL_TIMES, - RESOURCE, - SCAN_INTERVAL, + DOMAIN, ) +from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import load_fixture - config = { CONF_API_KEY: "foo", - SCAN_INTERVAL: timedelta(seconds=120), CONF_TRAVEL_TIMES: [{CONF_ID: 96, CONF_NAME: "I90 EB"}], } -async def test_setup_with_config(hass: HomeAssistant) -> None: +async def test_setup_with_config( + hass: HomeAssistant, mock_travel_time: AsyncMock +) -> None: """Test the platform setup with configuration.""" - assert await async_setup_component(hass, "sensor", {"wsdot": config}) + assert await async_setup_component( + hass, "sensor", {"sensor": [{CONF_PLATFORM: DOMAIN, **config}]} + ) - -async def test_setup(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: - """Test for operational WSDOT sensor with proper attributes.""" - entities = [] - - def add_entities(new_entities, update_before_add=False): - """Mock add entities.""" - for entity in new_entities: - entity.hass = hass - - if update_before_add: - for entity in new_entities: - entity.update() - - entities.extend(new_entities) - - uri = re.compile(RESOURCE + "*") - requests_mock.get(uri, text=load_fixture("wsdot/wsdot.json")) - wsdot.setup_platform(hass, config, add_entities) - assert len(entities) == 1 - sensor = entities[0] - assert sensor.name == "I90 EB" - assert sensor.state == 11 + state = hass.states.get("sensor.i90_eb") + assert state is not None + assert state.name == "I90 EB" + assert state.state == "11" assert ( - sensor.extra_state_attributes[ATTR_DESCRIPTION] + state.attributes["Description"] == "Downtown Seattle to Downtown Bellevue via I-90" ) - assert sensor.extra_state_attributes[ATTR_TIME_UPDATED] == datetime( + assert state.attributes["TimeUpdated"] == datetime( 2017, 1, 21, 15, 10, tzinfo=timezone(timedelta(hours=-8)) ) diff --git a/tests/components/wyoming/test_conversation.py b/tests/components/wyoming/test_conversation.py index 7278a254d4a..d3c60f9d0c6 100644 --- a/tests/components/wyoming/test_conversation.py +++ b/tests/components/wyoming/test_conversation.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from wyoming.asr import Transcript from wyoming.handle import Handled, NotHandled from wyoming.intent import Entity, Intent, NotRecognized diff --git a/tests/components/wyoming/test_stt.py b/tests/components/wyoming/test_stt.py index bd83c31c561..cfbcf24d405 100644 --- a/tests/components/wyoming/test_stt.py +++ b/tests/components/wyoming/test_stt.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from wyoming.asr import Transcript from homeassistant.components import stt diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index c52b1391038..c658bff1d0c 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -7,7 +7,7 @@ from unittest.mock import patch import wave import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from wyoming.audio import AudioChunk, AudioStop from homeassistant.components import tts, wyoming diff --git a/tests/components/yale/test_binary_sensor.py b/tests/components/yale/test_binary_sensor.py index 16ec0ffbeb4..95434b1b2d2 100644 --- a/tests/components/yale/test_binary_sensor.py +++ b/tests/components/yale/test_binary_sensor.py @@ -3,7 +3,7 @@ import datetime from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.const import ( diff --git a/tests/components/yale/test_diagnostics.py b/tests/components/yale/test_diagnostics.py index e5fd6b1c1a7..8a18f9ee791 100644 --- a/tests/components/yale/test_diagnostics.py +++ b/tests/components/yale/test_diagnostics.py @@ -1,6 +1,6 @@ """Test yale diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/yale/test_lock.py b/tests/components/yale/test_lock.py index 1a99cf967ba..50051913d5f 100644 --- a/tests/components/yale/test_lock.py +++ b/tests/components/yale/test_lock.py @@ -5,7 +5,7 @@ import datetime from aiohttp import ClientResponseError from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState diff --git a/tests/components/yale/test_sensor.py b/tests/components/yale/test_sensor.py index 5d724b4bb9d..1ee04bf1ee1 100644 --- a/tests/components/yale/test_sensor.py +++ b/tests/components/yale/test_sensor.py @@ -2,7 +2,7 @@ from typing import Any -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import core as ha from homeassistant.const import ( diff --git a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr index daa232ab141..2b732056991 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '1', diff --git a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr index 39b3ef09196..9724125b989 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr @@ -27,6 +27,7 @@ 'original_name': 'Battery', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'RF4-battery', @@ -75,6 +76,7 @@ 'original_name': 'Door', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'RF4', @@ -123,6 +125,7 @@ 'original_name': 'Battery', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'RF5-battery', @@ -171,6 +174,7 @@ 'original_name': 'Door', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'RF5', @@ -219,6 +223,7 @@ 'original_name': 'Battery', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'RF6-battery', @@ -267,6 +272,7 @@ 'original_name': 'Door', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'RF6', @@ -315,6 +321,7 @@ 'original_name': 'Battery', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', 'unique_id': '1-battery', @@ -363,6 +370,7 @@ 'original_name': 'Jam', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'jam', 'unique_id': '1-jam', @@ -411,6 +419,7 @@ 'original_name': 'Power loss', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_loss', 'unique_id': '1-acfail', @@ -459,6 +468,7 @@ 'original_name': 'Tamper', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tamper', 'unique_id': '1-tamper', diff --git a/tests/components/yale_smart_alarm/snapshots/test_button.ambr b/tests/components/yale_smart_alarm/snapshots/test_button.ambr index 7d52d1d7206..65c36cbddad 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_button.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_button.ambr @@ -27,6 +27,7 @@ 'original_name': 'Panic button', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'panic', 'unique_id': 'yale_smart_alarm-panic', diff --git a/tests/components/yale_smart_alarm/snapshots/test_lock.ambr b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr index e7c97b9001b..ebed9ac4316 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_lock.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr @@ -27,6 +27,7 @@ 'original_name': None, 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '1111', @@ -76,6 +77,7 @@ 'original_name': None, 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '2222', @@ -125,6 +127,7 @@ 'original_name': None, 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '3333', @@ -174,6 +177,7 @@ 'original_name': None, 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '7777', @@ -223,6 +227,7 @@ 'original_name': None, 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '8888', @@ -272,6 +277,7 @@ 'original_name': None, 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '9999', diff --git a/tests/components/yale_smart_alarm/snapshots/test_select.ambr b/tests/components/yale_smart_alarm/snapshots/test_select.ambr index 2899e716ea1..04ec15b6ccb 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_select.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_select.ambr @@ -33,6 +33,7 @@ 'original_name': 'Volume', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '1111-volume', @@ -91,6 +92,7 @@ 'original_name': 'Volume', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '2222-volume', @@ -149,6 +151,7 @@ 'original_name': 'Volume', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '3333-volume', @@ -207,6 +210,7 @@ 'original_name': 'Volume', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '7777-volume', @@ -265,6 +269,7 @@ 'original_name': 'Volume', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '8888-volume', @@ -323,6 +328,7 @@ 'original_name': 'Volume', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', 'unique_id': '9999-volume', diff --git a/tests/components/yale_smart_alarm/snapshots/test_switch.ambr b/tests/components/yale_smart_alarm/snapshots/test_switch.ambr index 17c44bf6ebf..451523fd51d 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_switch.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_switch.ambr @@ -27,6 +27,7 @@ 'original_name': 'Autolock', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'autolock', 'unique_id': '1111-autolock', @@ -74,6 +75,7 @@ 'original_name': 'Autolock', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'autolock', 'unique_id': '2222-autolock', @@ -121,6 +123,7 @@ 'original_name': 'Autolock', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'autolock', 'unique_id': '3333-autolock', @@ -168,6 +171,7 @@ 'original_name': 'Autolock', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'autolock', 'unique_id': '7777-autolock', @@ -215,6 +219,7 @@ 'original_name': 'Autolock', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'autolock', 'unique_id': '8888-autolock', @@ -262,6 +267,7 @@ 'original_name': 'Autolock', 'platform': 'yale_smart_alarm', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'autolock', 'unique_id': '9999-autolock', diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index 8cb28776d74..d4b7a1f4e5c 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 1', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'youless_localhost_delivery_low', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy export tariff 2', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'youless_localhost_delivery_high', @@ -127,12 +135,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total gas usage', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_gas_m3', 'unique_id': 'youless_localhost_gas', @@ -179,12 +191,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Average peak', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'average_peak', 'unique_id': 'youless_localhost_average_peak', @@ -231,12 +247,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 1', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_1_current', @@ -283,12 +303,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 2', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_2_current', @@ -335,12 +359,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current phase 3', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_3_current', @@ -387,12 +415,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current power usage', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_w', 'unique_id': 'youless_localhost_usage', @@ -439,12 +471,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 1', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'youless_localhost_power_low', @@ -491,12 +527,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy import tariff 2', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'youless_localhost_power_high', @@ -543,12 +583,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Month peak', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'month_peak', 'unique_id': 'youless_localhost_month_peak', @@ -595,12 +639,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power phase 1', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'youless_localhost_phase_1_power', @@ -647,12 +695,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power phase 2', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'youless_localhost_phase_2_power', @@ -699,12 +751,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power phase 3', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', 'unique_id': 'youless_localhost_phase_3_power', @@ -760,6 +816,7 @@ 'original_name': 'Tariff', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_tariff', 'unique_id': 'youless_localhost_tariff', @@ -808,12 +865,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy import', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', 'unique_id': 'youless_localhost_power_total', @@ -860,12 +921,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 1', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'youless_localhost_phase_1_voltage', @@ -912,12 +977,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 2', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'youless_localhost_phase_2_voltage', @@ -964,12 +1033,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage phase 3', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', 'unique_id': 'youless_localhost_phase_3_voltage', @@ -1016,12 +1089,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current usage', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'active_s0_w', 'unique_id': 'youless_localhost_extra_usage', @@ -1068,12 +1145,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_s0_kwh', 'unique_id': 'youless_localhost_extra_total', @@ -1120,12 +1201,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'youless', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_water', 'unique_id': 'youless_localhost_water', diff --git a/tests/components/youless/test_sensor.py b/tests/components/youless/test_sensor.py index 67dff314df7..e18ae678e42 100644 --- a/tests/components/youless/test_sensor.py +++ b/tests/components/youless/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/youtube/snapshots/test_sensor.ambr b/tests/components/youtube/snapshots/test_sensor.ambr index f4549e89c8c..feddd644cee 100644 --- a/tests/components/youtube/snapshots/test_sensor.ambr +++ b/tests/components/youtube/snapshots/test_sensor.ambr @@ -20,6 +20,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', 'friendly_name': 'Google for Developers Subscribers', + 'state_class': , 'unit_of_measurement': 'subscribers', }), 'context': , @@ -35,6 +36,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', 'friendly_name': 'Google for Developers Views', + 'state_class': , 'unit_of_measurement': 'views', }), 'context': , @@ -63,6 +65,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', 'friendly_name': 'Google for Developers Subscribers', + 'state_class': , 'unit_of_measurement': 'subscribers', }), 'context': , @@ -78,6 +81,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', 'friendly_name': 'Google for Developers Views', + 'state_class': , 'unit_of_measurement': 'views', }), 'context': , diff --git a/tests/components/youtube/test_diagnostics.py b/tests/components/youtube/test_diagnostics.py index 3a5765b5890..99d8b9d5185 100644 --- a/tests/components/youtube/test_diagnostics.py +++ b/tests/components/youtube/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the YouTube integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.youtube.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index e883347c8db..1090b8c391a 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from youtubeaio.types import UnauthorizedError, YouTubeBackendError from homeassistant import config_entries diff --git a/tests/components/zeversolar/snapshots/test_sensor.ambr b/tests/components/zeversolar/snapshots/test_sensor.ambr index f948eec79df..0c696dba5cb 100644 --- a/tests/components/zeversolar/snapshots/test_sensor.ambr +++ b/tests/components/zeversolar/snapshots/test_sensor.ambr @@ -23,12 +23,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Energy today', 'platform': 'zeversolar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_today', 'unique_id': '123456778_energy_today', @@ -75,12 +79,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'zeversolar', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pac', 'unique_id': '123456778_pac', diff --git a/tests/components/zeversolar/test_diagnostics.py b/tests/components/zeversolar/test_diagnostics.py index 0d7a919b023..b5a59b588fb 100644 --- a/tests/components/zeversolar/test_diagnostics.py +++ b/tests/components/zeversolar/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Zeversolar integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.zeversolar import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 89526f6431e..3935b66cc32 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -75,7 +75,7 @@ def update_attribute_cache(cluster): attrs.append(make_attribute(attrid, value)) hdr = make_zcl_header(zcl_f.GeneralCommand.Report_Attributes) - hdr.frame_control.disable_default_response = True + hdr.frame_control = hdr.frame_control.replace(disable_default_response=True) msg = zcl_f.GENERAL_COMMANDS[zcl_f.GeneralCommand.Report_Attributes].schema( attribute_reports=attrs ) @@ -119,7 +119,7 @@ async def send_attributes_report( ) hdr = make_zcl_header(zcl_f.GeneralCommand.Report_Attributes) - hdr.frame_control.disable_default_response = True + hdr.frame_control = hdr.frame_control.replace(disable_default_response=True) cluster.handle_message(hdr, msg) await hass.async_block_till_done() diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 180f16e9ae2..91f5e32942f 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -92,7 +92,7 @@ async def test_number(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None assert ( hass.states.get(entity_id).attributes.get("friendly_name") - == "FakeManufacturer FakeModel Number PWM1" + == "FakeManufacturer FakeModel PWM1" ) # change value from device diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index 0ff863f0c45..059210968df 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -18,7 +18,6 @@ from homeassistant.components.zha.repairs.network_settings_inconsistent import ( ISSUE_INCONSISTENT_NETWORK_SETTINGS, ) from homeassistant.components.zha.repairs.wrong_silabs_firmware import ( - DISABLE_MULTIPAN_URL, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, HardwareType, _detect_radio_hardware, @@ -110,17 +109,12 @@ def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("detected_hardware", "expected_learn_more_url"), - [ - (HardwareType.SKYCONNECT, DISABLE_MULTIPAN_URL[HardwareType.SKYCONNECT]), - (HardwareType.YELLOW, DISABLE_MULTIPAN_URL[HardwareType.YELLOW]), - (HardwareType.OTHER, None), - ], + ("detected_hardware"), + [HardwareType.SKYCONNECT, HardwareType.YELLOW, HardwareType.OTHER], ) async def test_multipan_firmware_repair( hass: HomeAssistant, detected_hardware: HardwareType, - expected_learn_more_url: str, config_entry: MockConfigEntry, mock_zigpy_connect: ControllerApplication, issue_registry: ir.IssueRegistry, @@ -159,7 +153,6 @@ async def test_multipan_firmware_repair( # The issue is created when we fail to probe assert issue is not None assert issue.translation_placeholders["firmware_type"] == "CPC" - assert issue.learn_more_url == expected_learn_more_url # If ZHA manages to start up normally after this, the issue will be deleted await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 863ea3964ab..2e6b9e8bd6a 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.0", UnitOfPressure.HPA) + assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) await send_attributes_report(hass, cluster, {0: 1000, 20: -1, 16: 10000}) - assert_state(hass, entity_id, "1000.0", UnitOfPressure.HPA) + assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) async def async_test_illuminance(hass: HomeAssistant, cluster: Cluster, entity_id: str): @@ -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.0", PERCENTAGE) + assert_state(hass, entity_id, "100", PERCENTAGE) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 1000}) - assert_state(hass, entity_id, "99.0", PERCENTAGE) + assert_state(hass, entity_id, "99", 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.0", PERCENTAGE) + assert_state(hass, entity_id, "100", PERCENTAGE) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 5000}) - assert_state(hass, entity_id, "99.0", PERCENTAGE) + assert_state(hass, entity_id, "99", PERCENTAGE) async def async_test_em_rms_current( @@ -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.0", "%") + assert_state(hass, entity_id, "1", "%") @pytest.mark.parametrize( diff --git a/tests/components/zimi/__init__.py b/tests/components/zimi/__init__.py new file mode 100644 index 00000000000..0e95ffc9c33 --- /dev/null +++ b/tests/components/zimi/__init__.py @@ -0,0 +1 @@ +"""Tests for the zimi component.""" diff --git a/tests/components/zimi/test_config_flow.py b/tests/components/zimi/test_config_flow.py new file mode 100644 index 00000000000..9ec0c624b6f --- /dev/null +++ b/tests/components/zimi/test_config_flow.py @@ -0,0 +1,371 @@ +"""Tests for the zimi config flow.""" + +from unittest.mock import MagicMock, patch + +import pytest +from zcc import ( + ControlPointCannotConnectError, + ControlPointConnectionRefusedError, + ControlPointDescription, + ControlPointError, + ControlPointInvalidHostError, + ControlPointTimeoutError, +) + +from homeassistant import config_entries +from homeassistant.components.zimi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + +INPUT_MAC = "aa:bb:cc:dd:ee:ff" +INPUT_MAC_EXTRA = "aa:bb:cc:dd:ee:ee" +INPUT_HOST = "192.168.1.100" +INPUT_HOST_EXTRA = "192.168.1.101" +INPUT_PORT = 5003 +INPUT_PORT_EXTRA = 5004 + +INVALID_INPUT_MAC = "xyz" +MISMATCHED_INPUT_MAC = "aa:bb:cc:dd:ee:ee" +SELECTED_HOST_AND_PORT = "selected_host_and_port" + + +@pytest.fixture +def discovery_mock(): + """Mock the ControlPointDiscoveryService.""" + with patch( + "homeassistant.components.zimi.config_flow.ControlPointDiscoveryService", + autospec=True, + ) as mock: + mock.return_value = mock + yield mock + + +async def test_user_discovery_success( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test user form transitions to creation if zcc discovery succeeds.""" + + discovery_mock.discovers.return_value = [ + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT) + ] + + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +async def test_user_discovery_success_selection( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test user form transitions via selection to creation if zcc discovery succeeds has multiple hosts.""" + + discovery_mock.discovers.return_value = [ + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT), + ControlPointDescription(host=INPUT_HOST_EXTRA, port=INPUT_PORT_EXTRA), + ] + + 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"] == "selection" + assert result["errors"] == {} + + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription( + host=INPUT_HOST_EXTRA, port=INPUT_PORT_EXTRA, mac=INPUT_MAC_EXTRA + ) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + SELECTED_HOST_AND_PORT: f"{INPUT_HOST_EXTRA}:{INPUT_PORT_EXTRA!s}", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "host": INPUT_HOST_EXTRA, + "port": INPUT_PORT_EXTRA, + "mac": format_mac(INPUT_MAC_EXTRA), + } + + +async def test_user_discovery_duplicates( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test that flow is aborted if duplicates are added.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id=INPUT_MAC, + data={ + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + "mac": format_mac(INPUT_MAC), + }, + ).add_to_hass(hass) + + discovery_mock.discovers.return_value = [ + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT) + ] + + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_finish_manual_success( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test manual form transitions to creation with valid data.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + 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" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +async def test_manual_cannot_connect( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test manual form transitions via cannot_connect to creation.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + + 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" + assert result["errors"] == {} + + # First attempt fails with CANNOT_CONNECT when attempting to connect + discovery_mock.return_value.validate_connection.side_effect = ( + ControlPointCannotConnectError + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {"base": "cannot_connect"} + + # Second attempt succeeds + discovery_mock.return_value.validate_connection.side_effect = None + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +async def test_manual_gethostbyname_error( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test manual form transitions via gethostbyname failure to creation.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + + 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" + assert result["errors"] == {} + + # First attempt fails with name lookup failure when attempting to connect + discovery_mock.return_value.validate_connection.side_effect = ( + ControlPointInvalidHostError + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] + assert result["errors"] == {"base": "invalid_host"} + + # Second attempt succeeds + discovery_mock.return_value.validate_connection.side_effect = None + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +@pytest.mark.parametrize( + ("side_effect", "error_expected"), + [ + ( + ControlPointInvalidHostError, + {"base": "invalid_host"}, + ), + ( + ControlPointConnectionRefusedError, + {"base": "connection_refused"}, + ), + ( + ControlPointCannotConnectError, + {"base": "cannot_connect"}, + ), + ( + ControlPointTimeoutError, + {"base": "timeout"}, + ), + ( + Exception, + {"base": "unknown"}, + ), + ], +) +async def test_manual_connection_errors( + hass: HomeAssistant, + discovery_mock: MagicMock, + side_effect: Exception, + error_expected: dict, +) -> None: + """Test manual form connection errors.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + + 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" + assert result["errors"] == {} + + # First attempt fails with connection errors + discovery_mock.return_value.validate_connection.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == error_expected + + # Second attempt succeeds + discovery_mock.return_value.validate_connection.side_effect = None + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 9da283d60cf..83a22cbee32 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -506,6 +506,22 @@ async def test_node_alerts( assert result["comments"] == [{"level": "info", "text": "test"}] assert result["is_embedded"] + # Test with node in interview + with patch("zwave_js_server.model.node.Node.in_interview", return_value=True): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_alerts", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["comments"]) == 2 + assert msg["result"]["comments"][1] == { + "level": "warning", + "text": "This device is currently being interviewed and may not be fully operational.", + } + # Test with provisioned device valid_qr_info = { VERSION: 1, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 03f68d29c46..dd8838e0775 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -13,15 +13,35 @@ from aiohasupervisor.models import AddonsOptions, Discovery import aiohttp import pytest from serial.tools.list_ports_common import ListPortInfo +from voluptuous import InInvalid from zwave_js_server.exceptions import FailedCommand +from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo from homeassistant import config_entries, data_entry_flow -from homeassistant.components.zwave_js.config_flow import TITLE -from homeassistant.components.zwave_js.const import ADDON_SLUG, CONF_USB_PATH, DOMAIN +from homeassistant.components.zwave_js.config_flow import TITLE, get_usb_ports +from homeassistant.components.zwave_js.const import ( + ADDON_SLUG, + CONF_ADDON_DEVICE, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, + CONF_LR_S2_ACCESS_CONTROL_KEY, + CONF_LR_S2_AUTHENTICATED_KEY, + CONF_S0_LEGACY_KEY, + CONF_S2_ACCESS_CONTROL_KEY, + CONF_S2_AUTHENTICATED_KEY, + CONF_S2_UNAUTHENTICATED_KEY, + CONF_USB_PATH, + DOMAIN, +) from homeassistant.components.zwave_js.helpers import SERVER_VERSION_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -63,6 +83,37 @@ CP2652_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( ) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture(name="discovery_info", autouse=True) +def discovery_info_fixture() -> list[Discovery]: + """Fixture to set up discovery info.""" + return [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + + +@pytest.fixture(name="discovery_info_side_effect", autouse=True) +def discovery_info_side_effect_fixture() -> Any | None: + """Return the discovery info from the supervisor.""" + return None + + +@pytest.fixture(name="get_addon_discovery_info", autouse=True) +def get_addon_discovery_info_fixture(get_addon_discovery_info: AsyncMock) -> AsyncMock: + """Get add-on discovery info.""" + return get_addon_discovery_info + + @pytest.fixture(name="setup_entry") def setup_entry_fixture() -> Generator[AsyncMock]: """Mock entry setup.""" @@ -197,11 +248,12 @@ async def test_manual(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "1234" -async def slow_server_version(*args): +async def slow_server_version(*args: Any) -> Any: """Simulate a slow server version.""" await asyncio.sleep(0.1) +@pytest.mark.usefixtures("integration") @pytest.mark.parametrize( ("url", "server_version_side_effect", "server_version_timeout", "error"), [ @@ -225,7 +277,7 @@ async def slow_server_version(*args): ), ], ) -async def test_manual_errors(hass: HomeAssistant, integration, url, error) -> None: +async def test_manual_errors(hass: HomeAssistant, url: str, error: str) -> None: """Test all errors with a manual set up.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -269,7 +321,10 @@ async def test_manual_errors(hass: HomeAssistant, integration, url, error) -> No ], ) async def test_reconfigure_manual_errors( - hass: HomeAssistant, integration, url, error + hass: HomeAssistant, + integration: MockConfigEntry, + url: str, + error: str, ) -> None: """Test all errors with a manual set up in a reconfigure flow.""" entry = integration @@ -330,13 +385,10 @@ async def test_manual_already_configured(hass: HomeAssistant) -> None: assert entry.data["integration_created_addon"] is False -@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +@pytest.mark.usefixtures("supervisor", "addon_running") async def test_supervisor_discovery( hass: HomeAssistant, - supervisor, - addon_running, - addon_options, - get_addon_discovery_info, + addon_options: dict[str, Any], ) -> None: """Test flow started from Supervisor discovery.""" @@ -389,13 +441,9 @@ async def test_supervisor_discovery( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("discovery_info", "server_version_side_effect"), - [({"config": ADDON_DISCOVERY_INFO}, TimeoutError())], -) -async def test_supervisor_discovery_cannot_connect( - hass: HomeAssistant, supervisor, get_addon_discovery_info -) -> None: +@pytest.mark.usefixtures("supervisor") +@pytest.mark.parametrize("server_version_side_effect", [TimeoutError()]) +async def test_supervisor_discovery_cannot_connect(hass: HomeAssistant) -> None: """Test Supervisor discovery and cannot connect.""" result = await hass.config_entries.flow.async_init( @@ -413,13 +461,11 @@ async def test_supervisor_discovery_cannot_connect( assert result["reason"] == "cannot_connect" -@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_clean_discovery_on_user_create( hass: HomeAssistant, supervisor, addon_running, addon_options, - get_addon_discovery_info, ) -> None: """Test discovery flow is cleaned up when a user flow is finished.""" @@ -448,6 +494,13 @@ async def test_clean_discovery_on_user_create( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -494,8 +547,10 @@ async def test_clean_discovery_on_user_create( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("supervisor", "addon_running") async def test_abort_discovery_with_existing_entry( - hass: HomeAssistant, supervisor, addon_running, addon_options + hass: HomeAssistant, + addon_options: dict[str, Any], ) -> None: """Test discovery flow is aborted if an entry already exists.""" @@ -524,17 +579,16 @@ async def test_abort_discovery_with_existing_entry( assert entry.data["url"] == "ws://host1:3001" -async def test_abort_hassio_discovery_with_existing_flow( - hass: HomeAssistant, supervisor, addon_installed, addon_options -) -> None: +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") +async def test_abort_hassio_discovery_with_existing_flow(hass: HomeAssistant) -> None: """Test hassio discovery flow is aborted when another discovery has happened.""" 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 result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" result2 = await hass.config_entries.flow.async_init( DOMAIN, @@ -551,9 +605,8 @@ async def test_abort_hassio_discovery_with_existing_flow( assert result2["reason"] == "already_in_progress" -async def test_abort_hassio_discovery_for_other_addon( - hass: HomeAssistant, supervisor, addon_installed, addon_options -) -> None: +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") +async def test_abort_hassio_discovery_for_other_addon(hass: HomeAssistant) -> None: """Test hassio discovery flow is aborted for a non official add-on discovery.""" result2 = await hass.config_entries.flow.async_init( DOMAIN, @@ -574,6 +627,7 @@ async def test_abort_hassio_discovery_for_other_addon( assert result2["reason"] == "not_zwave_js_addon" +@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") @pytest.mark.parametrize( ("usb_discovery_info", "device", "discovery_name"), [ @@ -596,29 +650,12 @@ async def test_abort_hassio_discovery_for_other_addon( ), ], ) -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) async def test_usb_discovery( hass: HomeAssistant, - supervisor, - addon_not_installed, - install_addon, - addon_options, - get_addon_discovery_info, + install_addon: AsyncMock, mock_usb_serial_by_id: MagicMock, - set_addon_options, - start_addon, + set_addon_options: AsyncMock, + start_addon: AsyncMock, usb_discovery_info: UsbServiceInfo, device: str, discovery_name: str, @@ -629,12 +666,15 @@ async def test_usb_discovery( context={"source": config_entries.SOURCE_USB}, 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"], {}) + assert mock_usb_serial_by_id.call_count == 1 + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + assert result["menu_options"] == ["intent_recommended", "intent_custom"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "install_addon" @@ -712,28 +752,13 @@ async def test_usb_discovery( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_installed") async def test_usb_discovery_addon_not_running( hass: HomeAssistant, - supervisor, - addon_installed, - addon_options, + addon_options: dict[str, Any], mock_usb_serial_by_id: MagicMock, - set_addon_options, - start_addon, - get_addon_discovery_info, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test usb discovery when add-on is installed but not running.""" addon_options["device"] = "/dev/incorrect_device" @@ -743,17 +768,21 @@ async def test_usb_discovery_addon_not_running( 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 mock_usb_serial_by_id.call_count == 2 + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_addon_user" # Make sure the discovered usb device is preferred. data_schema = result["data_schema"] + assert data_schema is not None assert data_schema({}) == { "s0_legacy_key": "", "s2_access_control_key": "", @@ -826,20 +855,7 @@ 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, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_running") async def test_usb_discovery_migration( hass: HomeAssistant, addon_options: dict[str, Any], @@ -909,12 +925,8 @@ async def test_usb_discovery_migration( 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"] is FlowResultType.FORM assert result["step_id"] == "intent_migrate" @@ -959,7 +971,7 @@ async def test_usb_discovery_migration( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 3 + assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -973,23 +985,11 @@ async def test_usb_discovery_migration( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device assert entry.data["use_addon"] is True + assert "keep_old_devices" not in entry.data assert entry.unique_id == "5678" -@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, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_running") async def test_usb_discovery_migration_restore_driver_ready_timeout( hass: HomeAssistant, addon_options: dict[str, Any], @@ -1053,12 +1053,8 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( 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"] is FlowResultType.FORM assert result["step_id"] == "intent_migrate" @@ -1104,7 +1100,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 3 + assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -1115,18 +1111,18 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( 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 + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device + assert entry.data["use_addon"] is True + assert "keep_old_devices" not in entry.data +@pytest.mark.usefixtures("supervisor", "addon_installed") async def test_discovery_addon_not_running( hass: HomeAssistant, - supervisor, - addon_installed, - addon_options, - set_addon_options, - start_addon, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test discovery with add-on already installed but not running.""" addon_options["device"] = None @@ -1214,14 +1210,12 @@ async def test_discovery_addon_not_running( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") async def test_discovery_addon_not_installed( hass: HomeAssistant, - supervisor, - addon_not_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test discovery with add-on not installed.""" result = await hass.config_entries.flow.async_init( @@ -1316,9 +1310,8 @@ async def test_discovery_addon_not_installed( assert len(mock_setup_entry.mock_calls) == 1 -async def test_abort_usb_discovery_with_existing_flow( - hass: HomeAssistant, supervisor, addon_options -) -> None: +@pytest.mark.usefixtures("supervisor", "addon_info") +async def test_abort_usb_discovery_with_existing_flow(hass: HomeAssistant) -> None: """Test usb discovery flow is aborted when another discovery has happened.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -1360,16 +1353,16 @@ async def test_usb_discovery_with_existing_usb_flow(hass: HomeAssistant) -> None data=first_usb_info, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "usb_confirm" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" 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" + assert result2["type"] is FlowResultType.MENU + assert result2["step_id"] == "installation_type" usb_flows_in_progress = hass.config_entries.flow.async_progress_by_handler( DOMAIN, match_context={"source": config_entries.SOURCE_USB} @@ -1383,9 +1376,8 @@ async def test_usb_discovery_with_existing_usb_flow(hass: HomeAssistant) -> None assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_abort_usb_discovery_addon_required( - hass: HomeAssistant, supervisor, addon_options -) -> None: +@pytest.mark.usefixtures("supervisor", "addon_info") +async def test_abort_usb_discovery_addon_required(hass: HomeAssistant) -> None: """Test usb discovery aborted when existing entry not using add-on.""" entry = MockConfigEntry( domain=DOMAIN, @@ -1404,53 +1396,6 @@ async def test_abort_usb_discovery_addon_required( 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: """Test usb discovery flow is aborted when there is no supervisor.""" result = await hass.config_entries.flow.async_init( @@ -1480,30 +1425,40 @@ async def test_usb_discovery_same_device( assert mock_usb_serial_by_id.call_count == 2 +@pytest.mark.usefixtures("supervisor", "addon_info") @pytest.mark.parametrize( - "discovery_info", + "usb_discovery_info", [CP2652_ZIGBEE_DISCOVERY_INFO], ) async def test_abort_usb_discovery_aborts_specific_devices( - hass: HomeAssistant, supervisor, addon_options, discovery_info + hass: HomeAssistant, + usb_discovery_info: UsbServiceInfo, ) -> None: """Test usb discovery flow is aborted on specific devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USB}, - data=discovery_info, + data=usb_discovery_info, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_zwave_device" -async def test_not_addon(hass: HomeAssistant, supervisor) -> None: +@pytest.mark.usefixtures("supervisor") +async def test_not_addon(hass: HomeAssistant) -> None: """Test opting out of add-on on Supervisor.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -1549,25 +1504,10 @@ async def test_not_addon(hass: HomeAssistant, supervisor) -> None: assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_running") async def test_addon_running( hass: HomeAssistant, - supervisor, - addon_running, - addon_options, - get_addon_discovery_info, + addon_options: dict[str, Any], ) -> None: """Test add-on already running on Supervisor.""" addon_options["device"] = "/test" @@ -1582,6 +1522,13 @@ async def test_addon_running( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -1617,6 +1564,7 @@ async def test_addon_running( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( "discovery_info", @@ -1679,11 +1627,8 @@ async def test_addon_running( ) async def test_addon_running_failures( hass: HomeAssistant, - supervisor, - addon_running, - addon_options, - get_addon_discovery_info, - abort_reason, + addon_options: dict[str, Any], + abort_reason: str, ) -> None: """Test all failures when add-on is running.""" addon_options["device"] = "/test" @@ -1693,6 +1638,13 @@ async def test_addon_running_failures( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -1704,25 +1656,10 @@ async def test_addon_running_failures( assert result["reason"] == abort_reason -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_running") async def test_addon_running_already_configured( hass: HomeAssistant, - supervisor, - addon_running, - addon_options, - get_addon_discovery_info, + addon_options: dict[str, Any], ) -> None: """Test that only one unique instance is allowed when add-on is running.""" addon_options["device"] = "/test_new" @@ -1756,6 +1693,13 @@ async def test_addon_running_already_configured( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -1775,27 +1719,11 @@ async def test_addon_running_already_configured( assert entry.data["lr_s2_authenticated_key"] == "new321" -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") async def test_addon_installed( hass: HomeAssistant, - supervisor, - addon_installed, - addon_options, - set_addon_options, - start_addon, - get_addon_discovery_info, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test add-on already installed but not running on Supervisor.""" @@ -1803,6 +1731,13 @@ async def test_addon_installed( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -1877,28 +1812,12 @@ async def test_addon_installed( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("discovery_info", "start_addon_side_effect"), - [ - ( - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ), - SupervisorError(), - ) - ], -) +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") +@pytest.mark.parametrize("start_addon_side_effect", [SupervisorError()]) async def test_addon_installed_start_failure( hass: HomeAssistant, - supervisor, - addon_installed, - addon_options, - set_addon_options, - start_addon, - get_addon_discovery_info, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test add-on start failure when add-on is installed.""" @@ -1906,6 +1825,13 @@ async def test_addon_installed_start_failure( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -1956,6 +1882,7 @@ async def test_addon_installed_start_failure( assert result["reason"] == "addon_start_failed" +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") @pytest.mark.parametrize( ("discovery_info", "server_version_side_effect"), [ @@ -1978,12 +1905,8 @@ async def test_addon_installed_start_failure( ) async def test_addon_installed_failures( hass: HomeAssistant, - supervisor, - addon_installed, - addon_options, - set_addon_options, - start_addon, - get_addon_discovery_info, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test all failures when add-on is installed.""" @@ -1991,6 +1914,13 @@ async def test_addon_installed_failures( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2041,30 +1971,12 @@ async def test_addon_installed_failures( assert result["reason"] == "addon_start_failed" -@pytest.mark.parametrize( - ("set_addon_options_side_effect", "discovery_info"), - [ - ( - SupervisorError(), - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], - ) - ], -) +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") +@pytest.mark.parametrize("set_addon_options_side_effect", [SupervisorError()]) async def test_addon_installed_set_options_failure( hass: HomeAssistant, - supervisor, - addon_installed, - addon_options, - set_addon_options, - start_addon, - get_addon_discovery_info, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test all failures when add-on is installed.""" @@ -2072,6 +1984,13 @@ async def test_addon_installed_set_options_failure( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2116,17 +2035,21 @@ 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: +@pytest.mark.usefixtures("supervisor", "addon_installed") +async def test_addon_installed_usb_ports_failure(hass: HomeAssistant) -> 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.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2142,27 +2065,11 @@ async def test_addon_installed_usb_ports_failure( assert result["reason"] == "usb_ports_failed" -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") async def test_addon_installed_already_configured( hass: HomeAssistant, - supervisor, - addon_installed, - addon_options, - set_addon_options, - start_addon, - get_addon_discovery_info, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test that only one unique instance is allowed when add-on is installed.""" entry = MockConfigEntry( @@ -2187,6 +2094,13 @@ async def test_addon_installed_already_configured( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2245,34 +2159,25 @@ async def test_addon_installed_already_configured( assert entry.data["lr_s2_authenticated_key"] == "new321" -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") async def test_addon_not_installed( hass: HomeAssistant, - supervisor, - addon_not_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, - get_addon_discovery_info, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test add-on not installed.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2357,8 +2262,10 @@ async def test_addon_not_installed( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("supervisor", "addon_not_installed") async def test_install_addon_failure( - hass: HomeAssistant, supervisor, addon_not_installed, install_addon + hass: HomeAssistant, + install_addon: AsyncMock, ) -> None: """Test add-on install failure.""" install_addon.side_effect = SupervisorError() @@ -2367,6 +2274,13 @@ async def test_install_addon_failure( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2387,7 +2301,11 @@ async def test_install_addon_failure( assert result["reason"] == "addon_install_failed" -async def test_reconfigure_manual(hass: HomeAssistant, client, integration) -> None: +async def test_reconfigure_manual( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, +) -> None: """Test manual settings in reconfigure flow.""" entry = integration hass.config_entries.async_update_entry(entry, unique_id="1234") @@ -2422,7 +2340,8 @@ async def test_reconfigure_manual(hass: HomeAssistant, client, integration) -> N async def test_reconfigure_manual_different_device( - hass: HomeAssistant, integration + hass: HomeAssistant, + integration: MockConfigEntry, ) -> None: """Test reconfigure flow manual step connecting to different device.""" entry = integration @@ -2449,8 +2368,11 @@ async def test_reconfigure_manual_different_device( assert result["reason"] == "different_device" +@pytest.mark.usefixtures("supervisor") async def test_reconfigure_not_addon( - hass: HomeAssistant, client, supervisor, integration + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test reconfigure flow and opting out of add-on on Supervisor.""" entry = integration @@ -2610,15 +2532,14 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( 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", "addon_running") @pytest.mark.parametrize( ( - "discovery_info", "entry_data", "old_addon_options", "new_addon_options", @@ -2626,14 +2547,6 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( ), [ ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], {}, { "device": "/test", @@ -2653,20 +2566,10 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, ), ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], {"use_addon": True}, { "device": "/test", @@ -2686,8 +2589,6 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 1, ), @@ -2695,19 +2596,15 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( ) async def test_reconfigure_addon_running( hass: HomeAssistant, - client, - supervisor, - integration, - addon_running, - addon_options, - set_addon_options, - restart_addon, - get_addon_discovery_info, - discovery_info, - entry_data, - old_addon_options, - new_addon_options, - disconnect_calls, + client: MagicMock, + integration: MockConfigEntry, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, + restart_addon: AsyncMock, + entry_data: dict[str, Any], + old_addon_options: dict[str, Any], + new_addon_options: dict[str, Any], + disconnect_calls: int, ) -> None: """Test reconfigure flow and add-on already running on Supervisor.""" addon_options.update(old_addon_options) @@ -2790,18 +2687,11 @@ async def test_reconfigure_addon_running( assert client.disconnect.call_count == 1 +@pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( - ("discovery_info", "entry_data", "old_addon_options", "new_addon_options"), + ("entry_data", "old_addon_options", "new_addon_options"), [ ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], {}, { "device": "/test", @@ -2812,8 +2702,6 @@ async def test_reconfigure_addon_running( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, { "usb_path": "/test", @@ -2823,26 +2711,20 @@ async def test_reconfigure_addon_running( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, ), ], ) async def test_reconfigure_addon_running_no_changes( hass: HomeAssistant, - client, - supervisor, - integration, - addon_running, - addon_options, - set_addon_options, - restart_addon, - get_addon_discovery_info, - discovery_info, - entry_data, - old_addon_options, - new_addon_options, + client: MagicMock, + integration: MockConfigEntry, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, + restart_addon: AsyncMock, + entry_data: dict[str, Any], + old_addon_options: dict[str, Any], + new_addon_options: dict[str, Any], ) -> None: """Test reconfigure flow without changes, and add-on already running on Supervisor.""" addon_options.update(old_addon_options) @@ -2925,9 +2807,9 @@ async def different_device_server_version(*args): ) +@pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( - "discovery_info", "entry_data", "old_addon_options", "new_addon_options", @@ -2936,14 +2818,6 @@ async def different_device_server_version(*args): ), [ ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], {}, { "device": "/test", @@ -2954,8 +2828,6 @@ async def different_device_server_version(*args): "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, { "usb_path": "/new", @@ -2965,43 +2837,6 @@ async def different_device_server_version(*args): "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, - }, - 0, - different_device_server_version, - ), - ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], - {}, - { - "device": "/test", - "network_key": "old123", - "s0_legacy_key": "old123", - "s2_access_control_key": "old456", - "s2_authenticated_key": "old789", - "s2_unauthenticated_key": "old987", - "lr_s2_access_control_key": "old654", - "lr_s2_authenticated_key": "old321", - "log_level": "info", - }, - { - "usb_path": "/new", - "s0_legacy_key": "new123", - "s2_access_control_key": "new456", - "s2_authenticated_key": "new789", - "s2_unauthenticated_key": "new987", - "lr_s2_access_control_key": "new654", - "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, different_device_server_version, @@ -3010,20 +2845,15 @@ async def different_device_server_version(*args): ) async def test_reconfigure_different_device( hass: HomeAssistant, - client, - supervisor, - integration, - addon_running, - addon_options, - set_addon_options, - restart_addon, - get_addon_discovery_info, - discovery_info, - entry_data, - old_addon_options, - new_addon_options, - disconnect_calls, - server_version_side_effect, + client: MagicMock, + integration: MockConfigEntry, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, + restart_addon: AsyncMock, + entry_data: dict[str, Any], + old_addon_options: dict[str, Any], + new_addon_options: dict[str, Any], + disconnect_calls: int, ) -> None: """Test reconfigure flow and configuring a different device.""" addon_options.update(old_addon_options) @@ -3077,8 +2907,7 @@ async def test_reconfigure_different_device( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - # Default emulate_hardware is False. - addon_options = {"emulate_hardware": False} | old_addon_options + addon_options = {} | old_addon_options # Legacy network key is not reset. addon_options.pop("network_key") @@ -3104,9 +2933,9 @@ async def test_reconfigure_different_device( assert client.disconnect.call_count == 1 +@pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( - "discovery_info", "entry_data", "old_addon_options", "new_addon_options", @@ -3115,14 +2944,6 @@ async def test_reconfigure_different_device( ), [ ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], {}, { "device": "/test", @@ -3133,8 +2954,6 @@ async def test_reconfigure_different_device( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, { "usb_path": "/new", @@ -3144,21 +2963,11 @@ async def test_reconfigure_different_device( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, [SupervisorError(), None], ), ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], {}, { "device": "/test", @@ -3169,8 +2978,6 @@ async def test_reconfigure_different_device( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, { "usb_path": "/new", @@ -3180,8 +2987,6 @@ async def test_reconfigure_different_device( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, [ @@ -3193,20 +2998,15 @@ async def test_reconfigure_different_device( ) async def test_reconfigure_addon_restart_failed( hass: HomeAssistant, - client, - supervisor, - integration, - addon_running, - addon_options, - set_addon_options, - restart_addon, - get_addon_discovery_info, - discovery_info, - entry_data, - old_addon_options, - new_addon_options, - disconnect_calls, - restart_addon_side_effect, + client: MagicMock, + integration: MockConfigEntry, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, + restart_addon: AsyncMock, + entry_data: dict[str, Any], + old_addon_options: dict[str, Any], + new_addon_options: dict[str, Any], + disconnect_calls: int, ) -> None: """Test reconfigure flow and add-on restart failure.""" addon_options.update(old_addon_options) @@ -3284,76 +3084,38 @@ async def test_reconfigure_addon_restart_failed( assert client.disconnect.call_count == 1 -@pytest.mark.parametrize( - ( - "discovery_info", - "entry_data", - "old_addon_options", - "new_addon_options", - "disconnect_calls", - "server_version_side_effect", - ), - [ - ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], - {}, - { - "device": "/test", - "network_key": "abc123", - "s0_legacy_key": "abc123", - "s2_access_control_key": "old456", - "s2_authenticated_key": "old789", - "s2_unauthenticated_key": "old987", - "lr_s2_access_control_key": "old654", - "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, - }, - { - "usb_path": "/test", - "s0_legacy_key": "abc123", - "s2_access_control_key": "old456", - "s2_authenticated_key": "old789", - "s2_unauthenticated_key": "old987", - "lr_s2_access_control_key": "old654", - "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, - }, - 0, - aiohttp.ClientError("Boom"), - ), - ], -) +@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon") +@pytest.mark.parametrize("server_version_side_effect", [aiohttp.ClientError("Boom")]) async def test_reconfigure_addon_running_server_info_failure( hass: HomeAssistant, - client, - supervisor, - integration, - addon_running, - addon_options, - set_addon_options, - restart_addon, - get_addon_discovery_info, - discovery_info, - entry_data, - old_addon_options, - new_addon_options, - disconnect_calls, - server_version_side_effect, + client: MagicMock, + integration: MockConfigEntry, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, ) -> None: """Test reconfigure flow and add-on already running with server info failure.""" + old_addon_options = { + "device": "/test", + "network_key": "abc123", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", + } + new_addon_options = { + "usb_path": "/test", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", + } addon_options.update(old_addon_options) entry = integration - data = {**entry.data, **entry_data} - hass.config_entries.async_update_entry(entry, data=data, unique_id="1234") + hass.config_entries.async_update_entry(entry, unique_id="1234") assert entry.data["url"] == "ws://test.org" @@ -3387,14 +3149,15 @@ async def test_reconfigure_addon_running_server_info_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" - assert entry.data == data + assert entry.data["url"] == "ws://test.org" + assert set_addon_options.call_count == 0 assert client.connect.call_count == 2 assert client.disconnect.call_count == 1 +@pytest.mark.usefixtures("supervisor", "addon_not_installed") @pytest.mark.parametrize( ( - "discovery_info", "entry_data", "old_addon_options", "new_addon_options", @@ -3402,14 +3165,6 @@ async def test_reconfigure_addon_running_server_info_failure( ), [ ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], {}, { "device": "/test", @@ -3429,20 +3184,10 @@ async def test_reconfigure_addon_running_server_info_failure( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, ), ( - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ], {"use_addon": True}, { "device": "/test", @@ -3462,8 +3207,6 @@ async def test_reconfigure_addon_running_server_info_failure( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 1, ), @@ -3471,20 +3214,16 @@ async def test_reconfigure_addon_running_server_info_failure( ) async def test_reconfigure_addon_not_installed( hass: HomeAssistant, - client, - supervisor, - addon_not_installed, - install_addon, - integration, - addon_options, - set_addon_options, - start_addon, - get_addon_discovery_info, - discovery_info, - entry_data, - old_addon_options, - new_addon_options, - disconnect_calls, + client: MagicMock, + install_addon: AsyncMock, + integration: MockConfigEntry, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, + start_addon: AsyncMock, + entry_data: dict[str, Any], + old_addon_options: dict[str, Any], + new_addon_options: dict[str, Any], + disconnect_calls: int, ) -> None: """Test reconfigure flow and add-on not installed on Supervisor.""" addon_options.update(old_addon_options) @@ -3610,7 +3349,10 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_reconfigure_migrate_no_addon(hass: HomeAssistant, integration) -> None: +async def test_reconfigure_migrate_no_addon( + hass: HomeAssistant, + integration: MockConfigEntry, +) -> None: """Test migration flow fails when not using add-on.""" entry = integration hass.config_entries.async_update_entry( @@ -3628,6 +3370,7 @@ async def test_reconfigure_migrate_no_addon(hass: HomeAssistant, integration) -> assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_required" + assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("mock_sdk_version") @@ -3652,21 +3395,10 @@ async def test_reconfigure_migrate_low_sdk_version( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "migration_low_sdk_version" + assert "keep_old_devices" not in entry.data -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( "reset_server_version_side_effect", @@ -3675,21 +3407,26 @@ async def test_reconfigure_migrate_low_sdk_version( "final_unique_id", ), [ - (None, "4321", None, "8765"), - (aiohttp.ClientError("Boom"), "1234", None, "8765"), + (None, "4321", None, "3245146787"), + (aiohttp.ClientError("Boom"), "3245146787", None, "3245146787"), (None, "4321", aiohttp.ClientError("Boom"), "5678"), - (aiohttp.ClientError("Boom"), "1234", aiohttp.ClientError("Boom"), "5678"), + ( + aiohttp.ClientError("Boom"), + "3245146787", + aiohttp.ClientError("Boom"), + "5678", + ), ], ) async def test_reconfigure_migrate_with_addon( hass: HomeAssistant, - client, - supervisor, - integration, - addon_running, - restart_addon, - set_addon_options, - get_addon_discovery_info, + client: MagicMock, + device_registry: dr.DeviceRegistry, + multisensor_6: Node, + integration: MockConfigEntry, + restart_addon: AsyncMock, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, get_server_version: AsyncMock, reset_server_version_side_effect: Exception | None, reset_unique_id: str, @@ -3702,15 +3439,49 @@ async def test_reconfigure_migrate_with_addon( version_info.home_id = 4321 entry = integration assert client.connect.call_count == 1 + assert client.driver.controller.home_id == 3245146787 hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, "usb_path": "/dev/ttyUSB0", }, ) + addon_options["device"] = "/dev/ttyUSB0" + + controller_node = client.driver.controller.own_node + controller_device_id = ( + f"{client.driver.controller.home_id}-{controller_node.node_id}" + ) + controller_device_id_ext = ( + f"{controller_device_id}-{controller_node.manufacturer_id}:" + f"{controller_node.product_type}:{controller_node.product_id}" + ) + + assert len(device_registry.devices) == 2 + # Verify there's a device entry for the controller. + device = device_registry.async_get_device( + identifiers={(DOMAIN, controller_device_id)} + ) + assert device + assert device == device_registry.async_get_device( + identifiers={(DOMAIN, controller_device_id_ext)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW090" + assert device.name == "Z‐Stick Gen5 USB Controller" + # Verify there's a device entry for the multisensor. + sensor_device_id = f"{client.driver.controller.home_id}-{multisensor_6.node_id}" + device = device_registry.async_get_device(identifiers={(DOMAIN, sensor_device_id)}) + assert device + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + assert device.name == "Multisensor 6" + # Customize the sensor device name. + device_registry.async_update_device( + device.id, name_by_user="Custom Sensor Device Name" + ) async def mock_backup_nvm_raw(): await asyncio.sleep(0) @@ -3740,6 +3511,7 @@ async def test_reconfigure_migrate_with_addon( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.emit( "driver ready", {"event": "driver ready", "source": "driver"} ) @@ -3786,7 +3558,12 @@ async def test_reconfigure_migrate_with_addon( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "choose_serial_port" - assert result["data_schema"].schema[CONF_USB_PATH] + data_schema = result["data_schema"] + assert data_schema is not None + assert data_schema.schema[CONF_USB_PATH] + # Ensure the old usb path is not in the list of options + with pytest.raises(InInvalid): + data_schema.schema[CONF_USB_PATH](addon_options["device"]) # Reset side effect before starting the add-on. get_server_version.side_effect = None @@ -3805,6 +3582,17 @@ async def test_reconfigure_migrate_with_addon( "core_zwave_js", AddonsOptions(config={"device": "/test"}) ) + # Simulate the new connected controller hardware labels. + # This will cause a new device entry to be created + # when the config entry is loaded before restoring NVM. + controller_node = client.driver.controller.own_node + controller_node.data["manufacturerId"] = 999 + controller_node.data["productId"] = 999 + controller_node.device_config.data["description"] = "New Device Name" + controller_node.device_config.data["label"] = "New Device Model" + controller_node.device_config.data["manufacturer"] = "New Device Manufacturer" + client.driver.controller.data["homeId"] = 5678 + await hass.async_block_till_done() assert restart_addon.call_args == call("core_zwave_js") @@ -3813,14 +3601,14 @@ async def test_reconfigure_migrate_with_addon( assert entry.unique_id == "5678" get_server_version.side_effect = restore_server_version_side_effect - version_info.home_id = 8765 + version_info.home_id = 3245146787 assert result["type"] is 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 client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -3834,31 +3622,37 @@ async def test_reconfigure_migrate_with_addon( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" assert entry.data["use_addon"] is True + assert "keep_old_devices" not in entry.data assert entry.unique_id == final_unique_id + assert len(device_registry.devices) == 2 + controller_device_id_ext = ( + f"{controller_device_id}-{controller_node.manufacturer_id}:" + f"{controller_node.product_type}:{controller_node.product_id}" + ) + device = device_registry.async_get_device( + identifiers={(DOMAIN, controller_device_id_ext)} + ) + assert device + assert device.manufacturer == "New Device Manufacturer" + assert device.model == "New Device Model" + assert device.name == "New Device Name" + device = device_registry.async_get_device(identifiers={(DOMAIN, sensor_device_id)}) + assert device + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + assert device.name == "Multisensor 6" + assert device.name_by_user == "Custom Sensor Device Name" + assert client.driver.controller.home_id == 3245146787 -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) + +@pytest.mark.usefixtures("supervisor", "addon_running") async def test_reconfigure_migrate_reset_driver_ready_timeout( hass: HomeAssistant, - client, - supervisor, - integration, - addon_running, - restart_addon, - set_addon_options, - get_addon_discovery_info, + client: MagicMock, + integration: MockConfigEntry, + restart_addon: AsyncMock, + set_addon_options: AsyncMock, get_server_version: AsyncMock, ) -> None: """Test migration flow with driver ready timeout after controller reset.""" @@ -3954,7 +3748,9 @@ async def test_reconfigure_migrate_reset_driver_ready_timeout( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "choose_serial_port" - assert result["data_schema"].schema[CONF_USB_PATH] + data_schema = result["data_schema"] + assert data_schema is not None + assert data_schema.schema[CONF_USB_PATH] result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -3982,7 +3778,7 @@ async def test_reconfigure_migrate_reset_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 3 + assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -3997,30 +3793,16 @@ async def test_reconfigure_migrate_reset_driver_ready_timeout( assert entry.data["usb_path"] == "/test" assert entry.data["use_addon"] is True assert entry.unique_id == "5678" + assert "keep_old_devices" not in entry.data -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_running") async def test_reconfigure_migrate_restore_driver_ready_timeout( hass: HomeAssistant, - client, - supervisor, - integration, - addon_running, - restart_addon, - set_addon_options, - get_addon_discovery_info, + client: MagicMock, + integration: MockConfigEntry, + restart_addon: AsyncMock, + set_addon_options: AsyncMock, ) -> None: """Test migration flow with driver ready timeout after nvm restore.""" entry = integration @@ -4105,7 +3887,9 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "choose_serial_port" - assert result["data_schema"].schema[CONF_USB_PATH] + data_schema = result["data_schema"] + assert data_schema is not None + assert data_schema.schema[CONF_USB_PATH] result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -4135,7 +3919,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 3 + assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -4146,13 +3930,16 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( 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 + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == "/test" + assert entry.data["use_addon"] is True + assert "keep_old_devices" not in entry.data async def test_reconfigure_migrate_backup_failure( - hass: HomeAssistant, integration, client + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, ) -> None: """Test backup failure.""" entry = integration @@ -4180,10 +3967,13 @@ async def test_reconfigure_migrate_backup_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" + assert "keep_old_devices" not in entry.data async def test_reconfigure_migrate_backup_file_failure( - hass: HomeAssistant, integration, client + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, ) -> None: """Test backup file failure.""" entry = integration @@ -4224,22 +4014,10 @@ async def test_reconfigure_migrate_backup_file_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" + assert "keep_old_devices" not in entry.data -@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, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_running") async def test_reconfigure_migrate_start_addon_failure( hass: HomeAssistant, client: MagicMock, @@ -4322,30 +4100,15 @@ async def test_reconfigure_migrate_start_addon_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_start_failed" + assert "keep_old_devices" not in entry.data -@pytest.mark.parametrize( - "discovery_info", - [ - [ - Discovery( - addon="core_zwave_js", - service="zwave_js", - uuid=uuid4(), - config=ADDON_DISCOVERY_INFO, - ) - ] - ], -) +@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon") async def test_reconfigure_migrate_restore_failure( hass: HomeAssistant, - client, - supervisor, - integration, - addon_running, - restart_addon, - set_addon_options, - get_addon_discovery_info, + client: MagicMock, + integration: MockConfigEntry, + set_addon_options: AsyncMock, ) -> None: """Test restore failure.""" entry = integration @@ -4411,6 +4174,7 @@ async def test_reconfigure_migrate_restore_failure( }, ) + assert set_addon_options.call_count == 1 assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" @@ -4428,7 +4192,11 @@ async def test_reconfigure_migrate_restore_failure( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "restore_failed" - assert result["description_placeholders"]["file_path"] + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["file_path"] + assert description_placeholders["file_url"] + assert description_placeholders["file_name"] result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -4447,6 +4215,7 @@ async def test_reconfigure_migrate_restore_failure( hass.config_entries.flow.async_abort(result["flow_id"]) assert len(hass.config_entries.flow.async_progress()) == 0 + assert "keep_old_devices" not in entry.data async def test_get_driver_failure_intent_migrate( @@ -4456,13 +4225,13 @@ async def test_get_driver_failure_intent_migrate( """Test get driver failure in intent migrate step.""" entry = integration hass.config_entries.async_update_entry( - integration, unique_id="1234", data={**integration.data, "use_addon": True} + 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" - await hass.config_entries.async_unload(integration.entry_id) + await hass.config_entries.async_unload(entry.entry_id) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "intent_migrate"} @@ -4470,6 +4239,7 @@ async def test_get_driver_failure_intent_migrate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "config_entry_not_loaded" + assert "keep_old_devices" not in entry.data async def test_get_driver_failure_instruct_unplug( @@ -4491,7 +4261,7 @@ async def test_get_driver_failure_instruct_unplug( ) entry = integration hass.config_entries.async_update_entry( - integration, unique_id="1234", data={**integration.data, "use_addon": True} + entry, unique_id="1234", data={**entry.data, "use_addon": True} ) result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4514,7 +4284,7 @@ async def test_get_driver_failure_instruct_unplug( assert client.driver.controller.async_backup_nvm_raw.call_count == 1 assert mock_file.call_count == 1 - await hass.config_entries.async_unload(integration.entry_id) + await hass.config_entries.async_unload(entry.entry_id) result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -4522,11 +4292,15 @@ async def test_get_driver_failure_instruct_unplug( assert result["reason"] == "config_entry_not_loaded" -async def test_hard_reset_failure(hass: HomeAssistant, integration, client) -> None: +async def test_hard_reset_failure( + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, +) -> None: """Test hard reset failure.""" entry = integration hass.config_entries.async_update_entry( - integration, unique_id="1234", data={**integration.data, "use_addon": True} + entry, unique_id="1234", data={**entry.data, "use_addon": True} ) async def mock_backup_nvm_raw(): @@ -4569,12 +4343,14 @@ async def test_hard_reset_failure(hass: HomeAssistant, integration, client) -> N async def test_choose_serial_port_usb_ports_failure( - hass: HomeAssistant, integration, client + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, ) -> 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} + entry, unique_id="1234", data={**entry.data, "use_addon": True} ) async def mock_backup_nvm_raw(): @@ -4629,8 +4405,10 @@ async def test_choose_serial_port_usb_ports_failure( assert result["reason"] == "usb_ports_failed" +@pytest.mark.usefixtures("supervisor", "addon_installed") async def test_configure_addon_usb_ports_failure( - hass: HomeAssistant, integration, addon_installed, supervisor + hass: HomeAssistant, + integration: MockConfigEntry, ) -> None: """Test configure addon usb ports failure.""" entry = integration @@ -4655,3 +4433,240 @@ async def test_configure_addon_usb_ports_failure( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "usb_ports_failed" + + +async def test_get_usb_ports_sorting() -> None: + """Test that get_usb_ports sorts ports with 'n/a' descriptions last.""" + mock_ports = [ + ListPortInfo("/dev/ttyUSB0"), + ListPortInfo("/dev/ttyUSB1"), + ListPortInfo("/dev/ttyUSB2"), + ListPortInfo("/dev/ttyUSB3"), + ] + mock_ports[0].description = "n/a" + mock_ports[1].description = "Device A" + mock_ports[2].description = "N/A" + mock_ports[3].description = "Device B" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that descriptions containing "n/a" are at the end + + assert descriptions == [ + "Device A - /dev/ttyUSB1, s/n: n/a", + "Device B - /dev/ttyUSB3, s/n: n/a", + "n/a - /dev/ttyUSB0, s/n: n/a", + "N/A - /dev/ttyUSB2, s/n: n/a", + ] + + +@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") +async def test_intent_recommended_user( + hass: HomeAssistant, + install_addon: AsyncMock, + start_addon: AsyncMock, + set_addon_options: AsyncMock, +) -> None: + """Test the intent_recommended step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_recommended"} + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + 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_user" + data_schema = result["data_schema"] + assert data_schema is not None + assert data_schema.schema[CONF_USB_PATH] is not None + assert data_schema.schema.get(CONF_S0_LEGACY_KEY) is None + assert data_schema.schema.get(CONF_S2_ACCESS_CONTROL_KEY) is None + assert data_schema.schema.get(CONF_S2_AUTHENTICATED_KEY) is None + assert data_schema.schema.get(CONF_S2_UNAUTHENTICATED_KEY) is None + assert data_schema.schema.get(CONF_LR_S2_ACCESS_CONTROL_KEY) is None + assert data_schema.schema.get(CONF_LR_S2_AUTHENTICATED_KEY) is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + CONF_ADDON_DEVICE: "/test", + CONF_ADDON_S0_LEGACY_KEY: "", + CONF_ADDON_S2_ACCESS_CONTROL_KEY: "", + CONF_ADDON_S2_AUTHENTICATED_KEY: "", + CONF_ADDON_S2_UNAUTHENTICATED_KEY: "", + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: "", + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: "", + } + ), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + with ( + patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test", + "s0_legacy_key": "", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", + "lr_s2_access_control_key": "", + "lr_s2_authenticated_key": "", + "use_addon": True, + "integration_created_addon": True, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") +@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", + ), + ], +) +async def test_recommended_usb_discovery( + hass: HomeAssistant, + install_addon: AsyncMock, + mock_usb_serial_by_id: MagicMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, + 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, + ) + + assert mock_usb_serial_by_id.call_count == 1 + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + assert result["menu_options"] == ["intent_recommended", "intent_custom"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_recommended"} + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + 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.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "device": device, + "s0_legacy_key": "", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", + "lr_s2_access_control_key": "", + "lr_s2_authenticated_key": "", + } + ), + ) + + with ( + patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": device, + "s0_legacy_key": "", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", + "lr_s2_access_control_key": "", + "lr_s2_authenticated_key": "", + "use_addon": True, + "integration_created_addon": True, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/zwave_js/test_helpers.py b/tests/components/zwave_js/test_helpers.py index 356707fb5f8..c163b8e8c75 100644 --- a/tests/components/zwave_js/test_helpers.py +++ b/tests/components/zwave_js/test_helpers.py @@ -23,6 +23,12 @@ from tests.common import MockConfigEntry CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + async def test_async_get_node_status_sensor_entity_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index a0423efdf52..ef74373ad9e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1692,27 +1692,6 @@ async def test_replace_different_node( (DOMAIN, multisensor_6_device_id_ext), } - ws_client = await hass_ws_client(hass) - - # Simulate the driver not being ready to ensure that the device removal handler - # does not crash - driver = client.driver - client.driver = None - - response = await ws_client.remove_device(hank_device.id, integration.entry_id) - assert not response["success"] - - client.driver = driver - - # Attempting to remove the hank device should pass, but removing the multisensor should not - response = await ws_client.remove_device(hank_device.id, integration.entry_id) - assert response["success"] - - response = await ws_client.remove_device( - multisensor_6_device.id, integration.entry_id - ) - assert not response["success"] - async def test_node_model_change( hass: HomeAssistant, diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 21a6c0a8fae..954d6422399 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -2,6 +2,7 @@ from copy import deepcopy +import pytest from zwave_js_server.event import Event from homeassistant.components.light import ( @@ -26,6 +27,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -42,6 +44,12 @@ ZDB5100_ENTITY = "light.matrix_office" HSM200_V1_ENTITY = "light.hsm200" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.LIGHT] + + async def test_light( hass: HomeAssistant, client, bulb_6_multi_color, integration ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 9b861d5bde5..d13384055b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,14 +42,17 @@ import respx from syrupy.assertion import SnapshotAssertion from syrupy.session import SnapshotSession +# Setup patching of JSON functions before any other Home Assistant imports +from . import patch_json # isort:skip + from homeassistant import block_async_io from homeassistant.exceptions import ServiceNotFound # Setup patching of recorder functions before any other Home Assistant imports -from . import patch_recorder +from . import patch_recorder # isort:skip # Setup patching of dt_util time functions before any other Home Assistant imports -from . import patch_time # noqa: F401, isort:skip +from . import patch_time # isort:skip from homeassistant import components, core as ha, loader, runner from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY @@ -187,14 +190,14 @@ def pytest_runtest_setup() -> None: pytest_socket.socket_allow_hosts(["127.0.0.1"]) pytest_socket.disable_socket(allow_unix_socket=True) - freezegun.api.datetime_to_fakedatetime = ha_datetime_to_fakedatetime # type: ignore[attr-defined] - freezegun.api.FakeDatetime = HAFakeDatetime # type: ignore[attr-defined] + freezegun.api.datetime_to_fakedatetime = patch_time.ha_datetime_to_fakedatetime # type: ignore[attr-defined] + freezegun.api.FakeDatetime = patch_time.HAFakeDatetime # type: ignore[attr-defined] def adapt_datetime(val): return val.isoformat(" ") # Setup HAFakeDatetime converter for sqlite3 - sqlite3.register_adapter(HAFakeDatetime, adapt_datetime) + sqlite3.register_adapter(patch_time.HAFakeDatetime, adapt_datetime) # Setup HAFakeDatetime converter for pymysql try: @@ -203,48 +206,11 @@ def pytest_runtest_setup() -> None: except ImportError: pass else: - MySQLdb_converters.conversions[HAFakeDatetime] = ( + MySQLdb_converters.conversions[patch_time.HAFakeDatetime] = ( MySQLdb_converters.DateTime2literal ) -def ha_datetime_to_fakedatetime(datetime) -> freezegun.api.FakeDatetime: # type: ignore[name-defined] - """Convert datetime to FakeDatetime. - - Modified to include https://github.com/spulec/freezegun/pull/424. - """ - return freezegun.api.FakeDatetime( # type: ignore[attr-defined] - datetime.year, - datetime.month, - datetime.day, - datetime.hour, - datetime.minute, - datetime.second, - datetime.microsecond, - datetime.tzinfo, - fold=datetime.fold, - ) - - -class HAFakeDatetime(freezegun.api.FakeDatetime): # type: ignore[name-defined] - """Modified to include https://github.com/spulec/freezegun/pull/424.""" - - @classmethod - def now(cls, tz=None): - """Return frozen now.""" - now = cls._time_to_freeze() or freezegun.api.real_datetime.now() - if tz: - result = tz.fromutc(now.replace(tzinfo=tz)) - else: - result = now - - # Add the _tz_offset only if it's non-zero to preserve fold - if cls._tz_offset(): - result += cls._tz_offset() - - return ha_datetime_to_fakedatetime(result) - - def check_real[**_P, _R](func: Callable[_P, Coroutine[Any, Any, _R]]): """Force a function to require a keyword _test_real to be passed in.""" @@ -449,6 +415,12 @@ def reset_globals() -> Generator[None]: frame.async_setup(None) frame._REPORTED_INTEGRATIONS.clear() + # Reset patch_json + if patch_json.mock_objects: + obj = patch_json.mock_objects.pop() + patch_json.mock_objects.clear() + pytest.fail(f"Test attempted to serialize mock object {obj}") + @pytest.fixture(autouse=True, scope="session") def bcrypt_cost() -> Generator[None]: diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index aac64f6139a..7285301f12b 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,6 +1,6 @@ """Test the condition helper.""" -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, patch @@ -8,7 +8,6 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant.components import automation from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -17,10 +16,8 @@ from homeassistant.const import ( CONF_DOMAIN, STATE_UNAVAILABLE, STATE_UNKNOWN, - SUN_EVENT_SUNRISE, - SUN_EVENT_SUNSET, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConditionError, HomeAssistantError from homeassistant.helpers import ( condition, @@ -32,8 +29,6 @@ from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.typing import WebSocketGenerator - def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. @@ -2242,1220 +2237,6 @@ async def test_condition_template_invalid_results(hass: HomeAssistant) -> None: assert not test(hass) -def _find_run_id(traces, trace_type, item_id): - """Find newest run_id for a script or automation.""" - for _trace in reversed(traces): - if _trace["domain"] == trace_type and _trace["item_id"] == item_id: - return _trace["run_id"] - - return None - - -async def assert_automation_condition_trace(hass_ws_client, automation_id, expected): - """Test the result of automation condition.""" - msg_id = 1 - - def next_id(): - nonlocal msg_id - msg_id += 1 - return msg_id - - client = await hass_ws_client() - - # List traces - await client.send_json( - {"id": next_id(), "type": "trace/list", "domain": "automation"} - ) - response = await client.receive_json() - assert response["success"] - run_id = _find_run_id(response["result"], "automation", automation_id) - - # Get trace - await client.send_json( - { - "id": next_id(), - "type": "trace/get", - "domain": "automation", - "item_id": "sun", - "run_id": run_id, - } - ) - response = await client.receive_json() - assert response["success"] - trace = response["result"] - assert len(trace["trace"]["condition/0"]) == 1 - condition_trace = trace["trace"]["condition/0"][0]["result"] - assert condition_trace == expected - - -async def test_if_action_before_sunrise_no_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise. - - Before sunrise is true from midnight until sunset, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise + 1s -> 'before sunrise' not true - now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = sunrise -> 'before sunrise' true - now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = local midnight -> 'before sunrise' true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = local midnight - 1s -> 'before sunrise' not true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, - ) - - -async def test_if_action_after_sunrise_no_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise. - - After sunrise is true from sunrise until midnight, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise - 1s -> 'after sunrise' not true - now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = sunrise + 1s -> 'after sunrise' true - now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = local midnight -> 'after sunrise' not true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = local midnight - 1s -> 'after sunrise' true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, - ) - - -async def test_if_action_before_sunrise_with_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise with offset. - - Before sunrise is true from midnight until sunset, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "before_offset": "+1:00:00", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunrise + 1h -> 'before sunrise' with offset +1h true - now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = UTC midnight -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local midnight -> 'before sunrise' with offset +1h true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local midnight - 1s -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunset -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunset -1s -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - -async def test_if_action_before_sunset_with_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunset with offset. - - Before sunset is true from midnight until sunset, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "before": "sunset", - "before_offset": "+1:00:00", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = local midnight -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true - now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunset + 1h -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = UTC midnight -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 3 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = UTC midnight - 1s -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 4 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunrise -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 5 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunrise -1s -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 6 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = local midnight-1s -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 6 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - -async def test_if_action_after_sunrise_with_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise with offset. - - After sunrise is true from sunrise until midnight, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "after_offset": "+1:00:00", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunrise + 1h -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = UTC noon -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local noon -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local noon - 1s -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 3 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunset -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 4 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunset + 1s -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 5 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local midnight-1s -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 6 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local midnight -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 6 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-17T14:33:57.053037+00:00"}, - ) - - -async def test_if_action_after_sunset_with_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunset with offset. - - After sunset is true from sunset until midnight, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "after": "sunset", - "after_offset": "+1:00:00", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true - now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunset + 1h -> 'after sunset' with offset +1h true - now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = midnight-1s -> 'after sunset' with offset +1h true - now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T02:55:06.099767+00:00"}, - ) - - # now = midnight -> 'after sunset' with offset +1h not true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, - ) - - -async def test_if_action_after_and_before_during( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise and before sunset. - - This is true from sunrise until sunset. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "before": SUN_EVENT_SUNSET, - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true - now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": False, - "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true - now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-17T01:53:44.723614+00:00"}, - ) - - # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true - now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunset - 1s -> 'after sunrise' + 'before sunset' true - now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = 9AM local -> 'after sunrise' + 'before sunset' true - now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 3 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - -async def test_if_action_before_or_after_during( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise or after sunset. - - This is true from midnight until sunrise and from sunset until midnight - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "after": SUN_EVENT_SUNSET, - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true - now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunset + 1s -> 'before sunrise' | 'after sunset' true - now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false - now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": False, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunset - 1s -> 'before sunrise' | 'after sunset' false - now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": False, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true - now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 3 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 4 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - -async def test_if_action_before_sunrise_no_offset_kotzebue( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise. - - Local timezone: Alaska time - Location: Kotzebue, which has a very skewed local timezone with sunrise - at 7 AM and sunset at 3AM during summer - After sunrise is true from sunrise until midnight, local time. - """ - await hass.config.async_set_time_zone("America/Anchorage") - hass.config.latitude = 66.5 - hass.config.longitude = 162.4 - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local - # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC - # now = sunrise + 1s -> 'before sunrise' not true - now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = sunrise - 1h -> 'before sunrise' true - now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = local midnight -> 'before sunrise' true - now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = local midnight - 1s -> 'before sunrise' not true - now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-07-23T15:12:19.155123+00:00"}, - ) - - -async def test_if_action_after_sunrise_no_offset_kotzebue( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise. - - Local timezone: Alaska time - Location: Kotzebue, which has a very skewed local timezone with sunrise - at 7 AM and sunset at 3AM during summer - Before sunrise is true from midnight until sunrise, local time. - """ - await hass.config.async_set_time_zone("America/Anchorage") - hass.config.latitude = 66.5 - hass.config.longitude = 162.4 - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local - # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC - # now = sunrise -> 'after sunrise' true - now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = sunrise - 1h -> 'after sunrise' not true - now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = local midnight -> 'after sunrise' not true - now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = local midnight - 1s -> 'after sunrise' true - now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-07-23T15:12:19.155123+00:00"}, - ) - - -async def test_if_action_before_sunset_no_offset_kotzebue( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise. - - Local timezone: Alaska time - Location: Kotzebue, which has a very skewed local timezone with sunrise - at 7 AM and sunset at 3AM during summer - Before sunset is true from midnight until sunset, local time. - """ - await hass.config.async_set_time_zone("America/Anchorage") - hass.config.latitude = 66.5 - hass.config.longitude = 162.4 - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNSET}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local - # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC - # now = sunset + 1s -> 'before sunset' not true - now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"}, - ) - - # now = sunset - 1h-> 'before sunset' true - now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"}, - ) - - # now = local midnight -> 'before sunrise' true - now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-07-24T11:17:54.446913+00:00"}, - ) - - # now = local midnight - 1s -> 'before sunrise' not true - now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-07-23T11:22:18.467277+00:00"}, - ) - - -async def test_if_action_after_sunset_no_offset_kotzebue( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise. - - Local timezone: Alaska time - Location: Kotzebue, which has a very skewed local timezone with sunrise - at 7 AM and sunset at 3AM during summer - After sunset is true from sunset until midnight, local time. - """ - await hass.config.async_set_time_zone("America/Anchorage") - hass.config.latitude = 66.5 - hass.config.longitude = 162.4 - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNSET}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local - # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC - # now = sunset -> 'after sunset' true - now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"}, - ) - - # now = sunset - 1s -> 'after sunset' not true - now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"}, - ) - - # now = local midnight -> 'after sunset' not true - now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-07-24T11:17:54.446913+00:00"}, - ) - - # now = local midnight - 1s -> 'after sunset' true - now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-07-23T11:22:18.467277+00:00"}, - ) - - async def test_trigger(hass: HomeAssistant) -> None: """Test trigger condition.""" config = {"alias": "Trigger Cond", "condition": "trigger", "id": "123456"} diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 5d16a9a62fd..f250f97cfd4 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -397,6 +397,14 @@ async def test_step_discovery( data=data_entry_flow.BaseServiceInfo(), ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "oauth_discovery" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "pick_implementation" @@ -418,6 +426,11 @@ async def test_abort_discovered_multiple( data=data_entry_flow.BaseServiceInfo(), ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "pick_implementation" diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index ecf5271dafd..aec687be40a 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1460,11 +1460,6 @@ def test_key_value_schemas_with_default() -> None: [ ({"delay": "{{ invalid"}, "should be format 'HH:MM'"), ({"wait_template": "{{ invalid"}, "invalid template"), - ({"condition": "invalid"}, "Unexpected value for condition: 'invalid'"), - ( - {"condition": "not", "conditions": {"condition": "invalid"}}, - "Unexpected value for condition: 'invalid'", - ), # The validation error message could be improved to explain that this is not # a valid shorthand template ( @@ -1496,7 +1491,7 @@ def test_key_value_schemas_with_default() -> None: ) @pytest.mark.usefixtures("hass") def test_script(caplog: pytest.LogCaptureFixture, config: dict, error: str) -> None: - """Test script validation is user friendly.""" + """Test script action validation is user friendly.""" with pytest.raises(vol.Invalid, match=error): cv.script_action(config) diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 852d418da23..266435ef05d 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -118,61 +118,75 @@ async def test_remove_stale_device_links_keep_entity_device( entity_registry: er.EntityRegistry, ) -> None: """Test cleaning works for entity.""" - config_entry = MockConfigEntry(domain="hue") - config_entry.add_to_hass(hass) + helper_config_entry = MockConfigEntry(domain="helper_integration") + helper_config_entry.add_to_hass(hass) + host_config_entry = MockConfigEntry(domain="host_integration") + host_config_entry.add_to_hass(hass) current_device = device_registry.async_get_or_create( identifiers={("test", "current_device")}, connections={("mac", "30:31:32:33:34:00")}, - config_entry_id=config_entry.entry_id, + config_entry_id=helper_config_entry.entry_id, ) - assert current_device is not None - device_registry.async_get_or_create( + stale_device_1 = device_registry.async_get_or_create( identifiers={("test", "stale_device_1")}, connections={("mac", "30:31:32:33:34:01")}, - config_entry_id=config_entry.entry_id, + config_entry_id=helper_config_entry.entry_id, ) device_registry.async_get_or_create( identifiers={("test", "stale_device_2")}, connections={("mac", "30:31:32:33:34:02")}, - config_entry_id=config_entry.entry_id, + config_entry_id=helper_config_entry.entry_id, ) - # Source entity registry + # Source entity source_entity = entity_registry.async_get_or_create( "sensor", - "test", + "host_integration", "source", - config_entry=config_entry, + config_entry=host_config_entry, device_id=current_device.id, ) - await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None - devices_config_entry = device_registry.devices.get_devices_for_config_entry_id( - config_entry.entry_id + # Helper entity connected to a stale device + helper_entity = entity_registry.async_get_or_create( + "sensor", + "helper_integration", + "helper", + config_entry=helper_config_entry, + device_id=stale_device_1.id, + ) + assert entity_registry.async_get(helper_entity.entity_id) is not None + + devices_helper_entry = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id ) # 3 devices linked to the config entry are expected (1 current device + 2 stales) - assert len(devices_config_entry) == 3 + assert len(devices_helper_entry) == 3 - # Manual cleanup should unlink stales devices from the config entry + # Manual cleanup should unlink stale devices from the config entry async_remove_stale_devices_links_keep_entity_device( hass, - entry_id=config_entry.entry_id, + entry_id=helper_config_entry.entry_id, source_entity_id_or_uuid=source_entity.entity_id, ) - devices_config_entry = device_registry.devices.get_devices_for_config_entry_id( - config_entry.entry_id + await hass.async_block_till_done() + + devices_helper_entry = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id ) - # After cleanup, only one device is expected to be linked to the config entry - assert len(devices_config_entry) == 1 - - assert current_device in devices_config_entry + # After cleanup, only one device is expected to be linked to the config entry, and + # the entities should exist and be linked to the current device + assert len(devices_helper_entry) == 1 + assert current_device in devices_helper_entry + assert entity_registry.async_get(source_entity.entity_id) is not None + assert entity_registry.async_get(helper_entity.entity_id) is not None async def test_remove_stale_devices_links_keep_current_device( diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 29edfb3fea7..45144627028 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -6,7 +6,7 @@ from datetime import datetime from functools import partial import time from typing import Any -from unittest.mock import patch +from unittest.mock import ANY, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -34,6 +34,32 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: return entry +@pytest.fixture +def mock_config_entry_with_subentries(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock config entry and add it to hass.""" + entry = MockConfigEntry( + title=None, + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ), + ) + entry.add_to_hass(hass) + return entry + + async def test_get_or_create_returns_same_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -3173,19 +3199,41 @@ async def test_cleanup_entity_registry_change( assert len(mock_call.mock_calls) == 2 +@pytest.mark.usefixtures("freezer") async def test_restore_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, + mock_config_entry_with_subentries: MockConfigEntry, ) -> None: """Make sure device id is stable.""" + entry_id = mock_config_entry_with_subentries.entry_id + subentry_id = "mock-subentry-id-1-1" update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, + config_entry_id=entry_id, + config_subentry_id=subentry_id, + configuration_url="http://config_url_orig.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig", identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_orig", + model="model_orig", + model_id="model_id_orig", + name="name_orig", + serial_number="serial_no_orig", + suggested_area="suggested_area_orig", + sw_version="version_orig", + via_device="via_device_id_orig", + ) + + # Apply user customizations + device_registry.async_update_device( + entry.id, + area_id="12345A", + disabled_by=dr.DeviceEntryDisabler.USER, + labels={"label1", "label2"}, + name_by_user="Test Friendly Name", ) assert len(device_registry.devices) == 1 @@ -3196,19 +3244,79 @@ async def test_restore_device( assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 1 + # This will create a new device entry2 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, + config_entry_id=entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, manufacturer="manufacturer", model="model", ) - entry3 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, + assert entry2 == dr.DeviceEntry( + area_id=None, + config_entries={entry_id}, + config_entries_subentries={entry_id: {None}}, + configuration_url=None, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:cd:ef:12")}, + created_at=utcnow(), + disabled_by=None, + entry_type=None, + hw_version=None, + id=ANY, + identifiers={("bridgeid", "4567")}, + labels={}, manufacturer="manufacturer", model="model", + model_id=None, + modified_at=utcnow(), + name_by_user=None, + name=None, + primary_config_entry=entry_id, + serial_number=None, + suggested_area=None, + sw_version=None, + ) + # This will restore the original device + entry3 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=subentry_id, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=None, + hw_version="hw_version_new", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + via_device="via_device_id_new", + ) + assert entry3 == dr.DeviceEntry( + area_id="suggested_area_new", + config_entries={entry_id}, + config_entries_subentries={entry_id: {subentry_id}}, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=None, + entry_type=None, + hw_version="hw_version_new", + id=entry.id, + identifiers={("bridgeid", "0123")}, + labels={}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + modified_at=utcnow(), + name_by_user=None, + name="name_new", + primary_config_entry=entry_id, + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", ) assert entry.id == entry3.id @@ -3222,129 +3330,186 @@ async def test_restore_device( await hass.async_block_till_done() - assert len(update_events) == 4 + assert len(update_events) == 5 assert update_events[0].data == { "action": "create", "device_id": entry.id, } assert update_events[1].data == { - "action": "remove", + "action": "update", + "changes": { + "area_id": "suggested_area_orig", + "disabled_by": None, + "labels": set(), + "name_by_user": None, + }, "device_id": entry.id, } assert update_events[2].data == { + "action": "remove", + "device_id": entry.id, + } + assert update_events[3].data == { "action": "create", "device_id": entry2.id, } - assert update_events[3].data == { - "action": "create", - "device_id": entry3.id, - } - - -async def test_restore_simple_device( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, -) -> None: - """Make sure device id is stable.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - device_registry.async_remove_device(entry.id) - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - - entry2 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, - identifiers={("bridgeid", "4567")}, - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - ) - - assert entry.id == entry3.id - assert entry.id != entry2.id - assert len(device_registry.devices) == 2 - assert len(device_registry.deleted_devices) == 0 - - await hass.async_block_till_done() - - assert len(update_events) == 4 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "remove", - "device_id": entry.id, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry2.id, - } - assert update_events[3].data == { + assert update_events[4].data == { "action": "create", "device_id": entry3.id, } +@pytest.mark.usefixtures("freezer") async def test_restore_shared_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Make sure device id is stable for shared devices.""" update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry() + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ), + ) config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() config_entry_2.add_to_hass(hass) entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + configuration_url="http://config_url_orig_1.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig_1", identifiers={("entry_123", "0123")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_orig_1", + model="model_orig_1", + model_id="model_id_orig_1", + name="name_orig_1", + serial_number="serial_no_orig_1", + suggested_area="suggested_area_orig_1", + sw_version="version_orig_1", + via_device="via_device_id_orig_1", ) assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 + # Add another config entry to the same device device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, + configuration_url="http://config_url_orig_2.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=None, + hw_version="hw_version_orig_2", identifiers={("entry_234", "2345")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_orig_2", + model="model_orig_2", + model_id="model_id_orig_2", + name="name_orig_2", + serial_number="serial_no_orig_2", + suggested_area="suggested_area_orig_2", + sw_version="version_orig_2", + via_device="via_device_id_orig_2", ) assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 + # Apply user customizations + updated_device = device_registry.async_update_device( + entry.id, + area_id="12345A", + disabled_by=dr.DeviceEntryDisabler.USER, + labels={"label1", "label2"}, + name_by_user="Test Friendly Name", + ) + + # Check device entry before we remove it + assert updated_device == dr.DeviceEntry( + area_id="12345A", + config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, + config_entries_subentries={ + config_entry_1.entry_id: {"mock-subentry-id-1-1"}, + config_entry_2.entry_id: {None}, + }, + configuration_url="http://config_url_orig_2.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=dr.DeviceEntryDisabler.USER, + entry_type=None, + hw_version="hw_version_orig_2", + id=entry.id, + identifiers={("entry_123", "0123"), ("entry_234", "2345")}, + labels={"label1", "label2"}, + manufacturer="manufacturer_orig_2", + model="model_orig_2", + model_id="model_id_orig_2", + modified_at=utcnow(), + name_by_user="Test Friendly Name", + name="name_orig_2", + primary_config_entry=config_entry_1.entry_id, + serial_number="serial_no_orig_2", + suggested_area="suggested_area_orig_2", + sw_version="version_orig_2", + ) + device_registry.async_remove_device(entry.id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 1 + # config_entry_1 restores the original device, only the supplied config entry, + # config subentry, connections, and identifiers will be restored entry2 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + configuration_url="http://config_url_new_1.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_new_1", identifiers={("entry_123", "0123")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_new_1", + model="model_new_1", + model_id="model_id_new_1", + name="name_new_1", + serial_number="serial_no_new_1", + suggested_area="suggested_area_new_1", + sw_version="version_new_1", + via_device="via_device_id_new_1", + ) + + assert entry2 == dr.DeviceEntry( + area_id="suggested_area_new_1", + config_entries={config_entry_1.entry_id}, + config_entries_subentries={config_entry_1.entry_id: {"mock-subentry-id-1-1"}}, + configuration_url="http://config_url_new_1.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=None, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_new_1", + id=entry.id, + identifiers={("entry_123", "0123")}, + labels={}, + manufacturer="manufacturer_new_1", + model="model_new_1", + model_id="model_id_new_1", + modified_at=utcnow(), + name_by_user=None, + name="name_new_1", + primary_config_entry=config_entry_1.entry_id, + serial_number="serial_no_new_1", + suggested_area="suggested_area_new_1", + sw_version="version_new_1", ) - assert entry.id == entry2.id assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 @@ -3352,17 +3517,55 @@ async def test_restore_shared_device( assert isinstance(entry2.connections, set) assert isinstance(entry2.identifiers, set) + # Remove the device again device_registry.async_remove_device(entry.id) + # config_entry_2 restores the original device, only the supplied config entry, + # config subentry, connections, and identifiers will be restored entry3 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, + configuration_url="http://config_url_new_2.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=None, + hw_version="hw_version_new_2", identifiers={("entry_234", "2345")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_new_2", + model="model_new_2", + model_id="model_id_new_2", + name="name_new_2", + serial_number="serial_no_new_2", + suggested_area="suggested_area_new_2", + sw_version="version_new_2", + via_device="via_device_id_new_2", + ) + + assert entry3 == dr.DeviceEntry( + area_id="suggested_area_new_2", + config_entries={config_entry_2.entry_id}, + config_entries_subentries={ + config_entry_2.entry_id: {None}, + }, + configuration_url="http://config_url_new_2.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=None, + entry_type=None, + hw_version="hw_version_new_2", + id=entry.id, + identifiers={("entry_234", "2345")}, + labels={}, + manufacturer="manufacturer_new_2", + model="model_new_2", + model_id="model_id_new_2", + modified_at=utcnow(), + name_by_user=None, + name="name_new_2", + primary_config_entry=config_entry_2.entry_id, + serial_number="serial_no_new_2", + suggested_area="suggested_area_new_2", + sw_version="version_new_2", ) - assert entry.id == entry3.id assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 @@ -3370,15 +3573,53 @@ async def test_restore_shared_device( assert isinstance(entry3.connections, set) assert isinstance(entry3.identifiers, set) + # Add config_entry_1 back to the restored device entry4 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + configuration_url="http://config_url_new_1.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_new_1", identifiers={("entry_123", "0123")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_new_1", + model="model_new_1", + model_id="model_id_new_1", + name="name_new_1", + serial_number="serial_no_new_1", + suggested_area="suggested_area_new_1", + sw_version="version_new_1", + via_device="via_device_id_new_1", + ) + + assert entry4 == dr.DeviceEntry( + area_id="suggested_area_new_2", + config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, + config_entries_subentries={ + config_entry_1.entry_id: {"mock-subentry-id-1-1"}, + config_entry_2.entry_id: {None}, + }, + configuration_url="http://config_url_new_1.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=None, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_new_1", + id=entry.id, + identifiers={("entry_123", "0123"), ("entry_234", "2345")}, + labels={}, + manufacturer="manufacturer_new_1", + model="model_new_1", + model_id="model_id_new_1", + modified_at=utcnow(), + name_by_user=None, + name="name_new_1", + primary_config_entry=config_entry_2.entry_id, + serial_number="serial_no_new_1", + suggested_area="suggested_area_new_1", + sw_version="version_new_1", ) - assert entry.id == entry4.id assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 @@ -3388,7 +3629,7 @@ async def test_restore_shared_device( await hass.async_block_till_done() - assert len(update_events) == 7 + assert len(update_events) == 8 assert update_events[0].data == { "action": "create", "device_id": entry.id, @@ -3398,33 +3639,65 @@ async def test_restore_shared_device( "device_id": entry.id, "changes": { "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, + "config_entries_subentries": { + config_entry_1.entry_id: {"mock-subentry-id-1-1"} + }, + "configuration_url": "http://config_url_orig_1.bla", + "entry_type": dr.DeviceEntryType.SERVICE, + "hw_version": "hw_version_orig_1", "identifiers": {("entry_123", "0123")}, + "manufacturer": "manufacturer_orig_1", + "model": "model_orig_1", + "model_id": "model_id_orig_1", + "name": "name_orig_1", + "serial_number": "serial_no_orig_1", + "suggested_area": "suggested_area_orig_1", + "sw_version": "version_orig_1", }, } assert update_events[2].data == { - "action": "remove", + "action": "update", "device_id": entry.id, + "changes": { + "area_id": "suggested_area_orig_1", + "disabled_by": None, + "labels": set(), + "name_by_user": None, + }, } assert update_events[3].data == { - "action": "create", + "action": "remove", "device_id": entry.id, } assert update_events[4].data == { - "action": "remove", - "device_id": entry.id, - } - assert update_events[5].data == { "action": "create", "device_id": entry.id, } + assert update_events[5].data == { + "action": "remove", + "device_id": entry.id, + } assert update_events[6].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[7].data == { "action": "update", "device_id": entry.id, "changes": { "config_entries": {config_entry_2.entry_id}, "config_entries_subentries": {config_entry_2.entry_id: {None}}, + "configuration_url": "http://config_url_new_2.bla", + "entry_type": None, + "hw_version": "hw_version_new_2", "identifiers": {("entry_234", "2345")}, + "manufacturer": "manufacturer_new_2", + "model": "model_new_2", + "model_id": "model_id_new_2", + "name": "name_new_2", + "serial_number": "serial_no_new_2", + "suggested_area": "suggested_area_new_2", + "sw_version": "version_new_2", }, } diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 8a1bdcb2f0c..08510364eba 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -56,7 +56,6 @@ from tests.common import ( _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" -PLATFORM = "test_platform" async def test_polling_only_updates_entities_it_should_poll( @@ -1551,6 +1550,7 @@ async def test_entity_info_added_to_entity_registry( original_icon="nice:icon", original_name="best name", options=None, + suggested_object_id=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 7df7bb398e8..554adff3700 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import MaxLengthExceeded from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util.dt import utc_from_timestamp +from homeassistant.util.dt import utc_from_timestamp, utcnow from tests.common import ( ANY, @@ -144,6 +144,7 @@ def test_get_or_create_updates_data( original_device_class="mock-device-class", original_icon="initial-original_icon", original_name="initial-original_name", + suggested_object_id=None, supported_features=5, translation_key="initial-translation_key", unit_of_measurement="initial-unit_of_measurement", @@ -202,6 +203,7 @@ def test_get_or_create_updates_data( original_device_class="new-mock-device-class", original_icon="updated-original_icon", original_name="updated-original_name", + suggested_object_id=None, supported_features=10, translation_key="updated-translation_key", unit_of_measurement="updated-unit_of_measurement", @@ -254,6 +256,7 @@ def test_get_or_create_updates_data( original_device_class=None, original_icon=None, original_name=None, + suggested_object_id=None, supported_features=0, # supported_features is stored as an int translation_key=None, unit_of_measurement=None, @@ -286,6 +289,24 @@ def test_get_or_create_suggested_object_id_conflict_existing( assert entry.entity_id == "light.hue_1234_2" +def test_remove(entity_registry: er.EntityRegistry) -> None: + """Test that we can remove an item.""" + entry = entity_registry.async_get_or_create("light", "hue", "1234") + + assert not entity_registry.deleted_entities + assert list(entity_registry.entities) == [entry.entity_id] + + # Remove the item + entity_registry.async_remove(entry.entity_id) + assert list(entity_registry.deleted_entities) == [("light", "hue", "1234")] + assert not entity_registry.entities + + # Remove the item again + entity_registry.async_remove(entry.entity_id) + assert list(entity_registry.deleted_entities) == [("light", "hue", "1234")] + assert not entity_registry.entities + + def test_create_triggers_save(entity_registry: er.EntityRegistry) -> None: """Test that registering entry triggers a save.""" with patch.object(entity_registry, "async_schedule_save") as mock_schedule_save: @@ -514,6 +535,7 @@ async def test_load_bad_data( { "aliases": [], "area_id": None, + "calculated_object_id": None, "capabilities": None, "categories": {}, "config_entry_id": None, @@ -537,6 +559,7 @@ async def test_load_bad_data( "original_name": None, "platform": "super_platform", "previous_unique_id": None, + "suggested_object_id": None, "supported_features": 0, "translation_key": None, "unique_id": 123, # Should trigger warning @@ -545,6 +568,7 @@ async def test_load_bad_data( { "aliases": [], "area_id": None, + "calculated_object_id": None, "capabilities": None, "categories": {}, "config_entry_id": None, @@ -568,6 +592,7 @@ async def test_load_bad_data( "original_name": None, "platform": "super_platform", "previous_unique_id": None, + "suggested_object_id": None, "supported_features": 0, "translation_key": None, "unique_id": ["not", "valid"], # Should not load @@ -922,6 +947,7 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) "original_name": None, "platform": "super_platform", "previous_unique_id": None, + "suggested_object_id": None, "supported_features": 0, "translation_key": None, "unique_id": "very_unique", @@ -1101,6 +1127,7 @@ async def test_migration_1_11( "original_name": None, "platform": "super_platform", "previous_unique_id": None, + "suggested_object_id": None, "supported_features": 0, "translation_key": None, "unique_id": "very_unique", @@ -2440,10 +2467,11 @@ def test_migrate_entity_to_new_platform_error_handling( async def test_restore_entity( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: - """Make sure entity registry id is stable and entity_id is reused if possible.""" + """Make sure entity registry id is stable.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) config_entry = MockConfigEntry( domain="light", @@ -2455,11 +2483,44 @@ async def test_restore_entity( title="Mock title", unique_id="test", ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), ], ) config_entry.add_to_hass(hass) + device_entry_1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_entry_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "22:34:56:AB:CD:EF")}, + ) entry1 = entity_registry.async_get_or_create( - "light", "hue", "1234", config_entry=config_entry + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id="mock-subentry-id-1-1", + device_id=device_entry_1.id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="suggested_1", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", ) entry2 = entity_registry.async_get_or_create( "light", @@ -2469,8 +2530,22 @@ async def test_restore_entity( config_subentry_id="mock-subentry-id-1-1", ) + # Apply user customizations entry1 = entity_registry.async_update_entity( - entry1.entity_id, new_entity_id="light.custom_1" + entry1.entity_id, + aliases={"alias1", "alias2"}, + area_id="12345A", + categories={"scope1": "id", "scope2": "id"}, + device_class="device_class_user", + disabled_by=er.RegistryEntryDisabler.USER, + hidden_by=er.RegistryEntryHider.USER, + icon="icon_user", + labels={"label1", "label2"}, + name="Test Friendly Name", + new_entity_id="light.custom_1", + ) + entry1 = entity_registry.async_update_entity_options( + entry1.entity_id, "options_domain", {"key": "value"} ) entity_registry.async_remove(entry1.entity_id) @@ -2478,17 +2553,62 @@ async def test_restore_entity( assert len(entity_registry.entities) == 0 assert len(entity_registry.deleted_entities) == 2 - # Re-add entities + # Re-add entities, integration has changed entry1_restored = entity_registry.async_get_or_create( - "light", "hue", "1234", config_entry=config_entry + "light", + "hue", + "1234", + capabilities={"key2": "value2"}, + config_entry=config_entry, + config_subentry_id="mock-subentry-id-1-2", + device_id=device_entry_2.id, + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + entity_category=EntityCategory.CONFIG, + get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, + has_entity_name=False, + hidden_by=None, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", ) entry2_restored = entity_registry.async_get_or_create("light", "hue", "5678") assert len(entity_registry.entities) == 2 assert len(entity_registry.deleted_entities) == 0 assert entry1 != entry1_restored - # entity_id is not restored - assert attr.evolve(entry1, entity_id="light.hue_1234") == entry1_restored + # entity_id and user customizations are not restored. new integration options are + # respected. + assert entry1_restored == er.RegistryEntry( + entity_id="light.suggested_2", + unique_id="1234", + platform="hue", + capabilities={"key2": "value2"}, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1-2", + created_at=utcnow(), + device_class=None, + device_id=device_entry_2.id, + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + hidden_by=None, + icon=None, + id=entry1.id, + modified_at=utcnow(), + name=None, + options={"test_domain": {"key2": "value2"}}, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) assert entry2 != entry2_restored # Config entry and subentry are not restored assert ( @@ -2534,23 +2654,33 @@ async def test_restore_entity( # Check the events await hass.async_block_till_done() - assert len(update_events) == 13 - assert update_events[0].data == {"action": "create", "entity_id": "light.hue_1234"} + assert len(update_events) == 14 + assert update_events[0].data == { + "action": "create", + "entity_id": "light.suggested_1", + } assert update_events[1].data == {"action": "create", "entity_id": "light.hue_5678"} assert update_events[2].data["action"] == "update" - assert update_events[3].data == {"action": "remove", "entity_id": "light.custom_1"} - assert update_events[4].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[3].data["action"] == "update" + assert update_events[4].data == {"action": "remove", "entity_id": "light.custom_1"} + assert update_events[5].data == {"action": "remove", "entity_id": "light.hue_5678"} # Restore entities the 1st time - assert update_events[5].data == {"action": "create", "entity_id": "light.hue_1234"} - assert update_events[6].data == {"action": "create", "entity_id": "light.hue_5678"} - assert update_events[7].data == {"action": "remove", "entity_id": "light.hue_1234"} - assert update_events[8].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[6].data == { + "action": "create", + "entity_id": "light.suggested_2", + } + assert update_events[7].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[8].data == { + "action": "remove", + "entity_id": "light.suggested_2", + } + assert update_events[9].data == {"action": "remove", "entity_id": "light.hue_5678"} # Restore entities the 2nd time - assert update_events[9].data == {"action": "create", "entity_id": "light.hue_1234"} - assert update_events[10].data == {"action": "create", "entity_id": "light.hue_5678"} - assert update_events[11].data == {"action": "remove", "entity_id": "light.hue_1234"} + assert update_events[10].data == {"action": "create", "entity_id": "light.hue_1234"} + assert update_events[11].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[12].data == {"action": "remove", "entity_id": "light.hue_1234"} # Restore entities the 3rd time - assert update_events[12].data == {"action": "create", "entity_id": "light.hue_1234"} + assert update_events[13].data == {"action": "create", "entity_id": "light.hue_1234"} async def test_async_migrate_entry_delete_self( diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index b8bc89e29d7..465d1b1778b 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3605,7 +3605,7 @@ async def test_track_time_interval_name(hass: HomeAssistant) -> None: timedelta(seconds=10), name=unique_string, ) - scheduled = getattr(hass.loop, "_scheduled") + scheduled = hass.loop._scheduled assert any(handle for handle in scheduled if unique_string in str(handle)) unsub() diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py new file mode 100644 index 00000000000..47f1b62feb7 --- /dev/null +++ b/tests/helpers/test_helper_integration.py @@ -0,0 +1,427 @@ +"""Tests for the helper entity helpers.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock + +import pytest + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) + +HELPER_DOMAIN = "helper" +SOURCE_DOMAIN = "test" + + +@pytest.fixture +def source_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a source config entry.""" + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + return source_config_entry + + +@pytest.fixture +def source_device( + device_registry: dr.DeviceRegistry, + source_config_entry: ConfigEntry, +) -> dr.DeviceEntry: + """Fixture to create a source device.""" + return device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def source_entity_entry( + entity_registry: er.EntityRegistry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a source entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + SOURCE_DOMAIN, + "unique", + config_entry=source_config_entry, + device_id=source_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def helper_config_entry( + hass: HomeAssistant, + source_entity_entry: er.RegistryEntry, + use_entity_registry_id: bool, +) -> MockConfigEntry: + """Fixture to create a helper config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=HELPER_DOMAIN, + options={ + "name": "My helper", + "round": 1.0, + "source": source_entity_entry.id + if use_entity_registry_id + else source_entity_entry.entity_id, + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My helper", + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +@pytest.fixture +def mock_helper_flow() -> Generator[None]: + """Mock helper config flow.""" + + class MockConfigFlow: + """Mock the helper config flow.""" + + VERSION = 1 + MINOR_VERSION = 1 + + with mock_config_flow(HELPER_DOMAIN, MockConfigFlow): + yield + + +@pytest.fixture +def helper_entity_entry( + entity_registry: er.EntityRegistry, + helper_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a helper entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + HELPER_DOMAIN, + helper_config_entry.entry_id, + config_entry=helper_config_entry, + device_id=source_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def async_remove_entry() -> AsyncMock: + """Fixture to mock async_remove_entry.""" + return AsyncMock(return_value=True) + + +@pytest.fixture +def async_unload_entry() -> AsyncMock: + """Fixture to mock async_unload_entry.""" + return AsyncMock(return_value=True) + + +@pytest.fixture +def set_source_entity_id_or_uuid() -> Mock: + """Fixture to mock set_source_entity_id_or_uuid.""" + return Mock() + + +@pytest.fixture +def source_entity_removed() -> AsyncMock: + """Fixture to mock source_entity_removed.""" + return AsyncMock() + + +@pytest.fixture +def mock_helper_integration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, + source_entity_removed: AsyncMock, +) -> None: + """Mock the helper integration.""" + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Mock setup entry.""" + async_handle_source_entity_changes( + hass, + helper_config_entry_id=helper_config_entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=source_entity_entry.device_id, + source_entity_id_or_uuid=helper_config_entry.options["source"], + source_entity_removed=source_entity_removed, + ) + return True + + mock_integration( + hass, + MockModule( + HELPER_DOMAIN, + async_remove_entry=async_remove_entry, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, f"{HELPER_DOMAIN}.config_flow", None) + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, + source_entity_removed: AsyncMock, +) -> None: + """Test the helper config entry is removed when the source entity is removed.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Remove the source entitys's config entry from the device, this removes the + # source entity + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Check that the source_entity_removed callback was called + source_entity_removed.assert_called_once() + async_unload_entry.assert_not_called() + async_remove_entry.assert_not_called() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is not removed from the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, +) -> None: + """Test the source entity removed from the source device.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Remove the source entity from the device + entity_registry.async_update_entity(source_entity_entry.entity_id, device_id=None) + await hass.async_block_till_done() + async_remove_entry.assert_not_called() + async_unload_entry.assert_called_once() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is removed from the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id not in source_device.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, +) -> None: + """Test the source entity is moved to another device.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + + # Create another device to move the source entity to + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert helper_config_entry.entry_id not in source_device_2.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Move the source entity to another device + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + async_remove_entry.assert_not_called() + async_unload_entry.assert_called_once() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is moved to the other device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert helper_config_entry.entry_id in source_device_2.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize( + ("use_entity_registry_id", "unload_calls", "set_source_entity_id_calls"), + [(True, 1, 0), (False, 0, 1)], +) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, + unload_calls: int, + set_source_entity_id_calls: int, +) -> None: + """Test the source entity's entity ID is changed.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Change the source entity's entity ID + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + async_remove_entry.assert_not_called() + assert len(async_unload_entry.mock_calls) == unload_calls + assert len(set_source_entity_id_or_uuid.mock_calls) == set_source_entity_id_calls + + # Check that the helper config is still in the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 4582bce3e05..38e7e1ae452 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -32,6 +32,7 @@ from homeassistant.core import ( HassJob, HomeAssistant, ServiceCall, + ServiceResponse, SupportsResponse, ) from homeassistant.helpers import ( @@ -1648,6 +1649,33 @@ async def test_register_admin_service( assert calls[0].context.user_id == hass_admin_user.id +@pytest.mark.parametrize( + "supports_response", + [SupportsResponse.ONLY, SupportsResponse.OPTIONAL], +) +async def test_register_admin_service_return_response( + hass: HomeAssistant, supports_response: SupportsResponse +) -> None: + """Test the register admin service for a service that returns response data.""" + + async def mock_service(call: ServiceCall) -> ServiceResponse: + """Service handler coroutine.""" + assert call.return_response + return {"test-reply": "test-value1"} + + service.async_register_admin_service( + hass, "test", "test", mock_service, supports_response=supports_response + ) + result = await hass.services.async_call( + "test", + "test", + service_data={}, + blocking=True, + return_response=True, + ) + assert result == {"test-reply": "test-value1"} + + async def test_domain_control_not_async(hass: HomeAssistant, mock_entities) -> None: """Test domain verification in a service call with an unknown user.""" calls = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 43efe79e96f..8e6e7643df3 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -16,7 +16,7 @@ from freezegun import freeze_time import orjson import pytest from pytest_unordered import unordered -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant import config_entries @@ -772,6 +772,79 @@ def test_add(hass: HomeAssistant) -> None: assert render(hass, "{{ 'no_number' | add(10, default=1) }}") == 1 +def test_apply(hass: HomeAssistant) -> None: + """Test apply.""" + assert template.Template( + """ + {%- macro add_foo(arg) -%} + {{arg}}foo + {%- endmacro -%} + {{ ["a", "b", "c"] | map('apply', add_foo) | list }} + """, + hass, + ).async_render() == ["afoo", "bfoo", "cfoo"] + + assert template.Template( + """ + {{ ['1', '2', '3', '4', '5'] | map('apply', int) | list }} + """, + hass, + ).async_render() == [1, 2, 3, 4, 5] + + +def test_apply_macro_with_arguments(hass: HomeAssistant) -> None: + """Test apply macro with positional, named, and mixed arguments.""" + # Test macro with positional arguments + assert template.Template( + """ + {%- macro greet(name, greeting) -%} + {{ greeting }}, {{ name }}! + {%- endmacro %} + {{ ["Alice", "Bob"] | map('apply', greet, "Hello") | list }} + """, + hass, + ).async_render() == ["Hello, Alice!", "Hello, Bob!"] + + # Test macro with named arguments + assert template.Template( + """ + {%- macro greet(name, greeting="Hi") -%} + {{ greeting }}, {{ name }}! + {%- endmacro %} + {{ ["Alice", "Bob"] | map('apply', greet, greeting="Hello") | list }} + """, + hass, + ).async_render() == ["Hello, Alice!", "Hello, Bob!"] + + # Test macro with mixed positional and named arguments + assert template.Template( + """ + {%- macro greet(name, separator, greeting="Hi") -%} + {{ greeting }}{{separator}} {{ name }}! + {%- endmacro %} + {{ ["Alice", "Bob"] | map('apply', greet, "," , greeting="Hey") | list }} + """, + hass, + ).async_render() == ["Hey, Alice!", "Hey, Bob!"] + + +def test_as_function(hass: HomeAssistant) -> None: + """Test as_function.""" + assert ( + template.Template( + """ + {%- macro macro_double(num, returns) -%} + {%- do returns(num * 2) -%} + {%- endmacro -%} + {%- set double = macro_double | as_function -%} + {{ double(5) }} + """, + hass, + ).async_render() + == 10 + ) + + def test_logarithm(hass: HomeAssistant) -> None: """Test logarithm.""" tests = [ @@ -1632,14 +1705,27 @@ def test_ord(hass: HomeAssistant) -> None: assert template.Template('{{ "d" | ord }}', hass).async_render() == 100 -def test_base64_encode(hass: HomeAssistant) -> None: - """Test the base64_encode filter.""" +def test_from_hex(hass: HomeAssistant) -> None: + """Test the fromhex filter.""" assert ( - template.Template('{{ "homeassistant" | base64_encode }}', hass).async_render() - == "aG9tZWFzc2lzdGFudA==" + template.Template("{{ '0F010003' | from_hex }}", hass).async_render() + == b"\x0f\x01\x00\x03" ) +@pytest.mark.parametrize( + ("value_template", "expected"), + [ + ('{{ "homeassistant" | base64_encode }}', "aG9tZWFzc2lzdGFudA=="), + ("{{ int('0F010003', base=16) | pack('>I') | base64_encode }}", "DwEAAw=="), + ("{{ 'AA01000200150020' | from_hex | base64_encode }}", "qgEAAgAVACA="), + ], +) +def test_base64_encode(hass: HomeAssistant, value_template: str, expected: str) -> None: + """Test the base64_encode filter.""" + assert template.Template(value_template, hass).async_render() == expected + + def test_base64_decode(hass: HomeAssistant) -> None: """Test the base64_decode filter.""" assert ( diff --git a/tests/non_packaged_scripts/test_alexa_locales.py b/tests/non_packaged_scripts/test_alexa_locales.py index ea139f7de8e..35a44fa74d4 100644 --- a/tests/non_packaged_scripts/test_alexa_locales.py +++ b/tests/non_packaged_scripts/test_alexa_locales.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest import requests_mock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from script.alexa_locales import SITE, run_script diff --git a/tests/patch_json.py b/tests/patch_json.py new file mode 100644 index 00000000000..e741ba1a816 --- /dev/null +++ b/tests/patch_json.py @@ -0,0 +1,37 @@ +"""Patch JSON related functions.""" + +from __future__ import annotations + +import functools +from typing import Any +from unittest import mock + +import orjson + +from homeassistant.helpers import json as json_helper + +real_json_encoder_default = json_helper.json_encoder_default + +mock_objects = [] + + +def json_encoder_default(obj: Any) -> Any: + """Convert Home Assistant objects. + + Hand other objects to the original method. + """ + if isinstance(obj, mock.Base): + mock_objects.append(obj) + raise TypeError(f"Attempting to serialize mock object {obj}") + return real_json_encoder_default(obj) + + +json_helper.json_encoder_default = json_encoder_default +json_helper.json_bytes = functools.partial( + orjson.dumps, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default +) +json_helper.json_bytes_sorted = functools.partial( + orjson.dumps, + option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SORT_KEYS, + default=json_encoder_default, +) diff --git a/tests/patch_time.py b/tests/patch_time.py index 362296ab8b2..76d31d6a75a 100644 --- a/tests/patch_time.py +++ b/tests/patch_time.py @@ -5,6 +5,49 @@ from __future__ import annotations import datetime import time +import freezegun + + +def ha_datetime_to_fakedatetime(datetime) -> freezegun.api.FakeDatetime: # type: ignore[name-defined] + """Convert datetime to FakeDatetime. + + Modified to include https://github.com/spulec/freezegun/pull/424. + """ + return freezegun.api.FakeDatetime( # type: ignore[attr-defined] + datetime.year, + datetime.month, + datetime.day, + datetime.hour, + datetime.minute, + datetime.second, + datetime.microsecond, + datetime.tzinfo, + fold=datetime.fold, + ) + + +class HAFakeDatetime(freezegun.api.FakeDatetime): # type: ignore[name-defined] + """Modified to include https://github.com/spulec/freezegun/pull/424.""" + + @classmethod + def now(cls, tz=None): + """Return frozen now.""" + now = cls._time_to_freeze() or freezegun.api.real_datetime.now() + if tz: + result = tz.fromutc(now.replace(tzinfo=tz)) + else: + result = now + + # Add the _tz_offset only if it's non-zero to preserve fold + if cls._tz_offset(): + result += cls._tz_offset() + + return ha_datetime_to_fakedatetime(result) + + +# Needed by Mashumaro +datetime.HAFakeDatetime = HAFakeDatetime + # Do not add any Home Assistant import here diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index c9748cc61f8..9179a545256 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -99,7 +99,7 @@ def test_regex_a_or_b( "code", [ """ - async def setup( #@ + async def async_turn_on( #@ arg1, arg2 ): pass @@ -115,7 +115,7 @@ def test_ignore_no_annotations( func_node = astroid.extract_node( code, - "homeassistant.components.pylint_test", + "homeassistant.components.pylint_test.light", ) type_hint_checker.visit_module(func_node.parent) diff --git a/tests/ruff.toml b/tests/ruff.toml index c56b8f68ffc..b22f39f1525 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -13,6 +13,7 @@ extend-ignore = [ [lint.flake8-tidy-imports.banned-api] "async_timeout".msg = "use asyncio.timeout instead" "pytz".msg = "use zoneinfo instead" +"syrupy.SnapshotAssertion".msg = "use syrupy.assertion.SnapshotAssertion instead" [lint.isort] known-first-party = [ diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index ba599c88518..55b8434160e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1365,42 +1365,6 @@ async def test_forward_entry_does_not_setup_entry_if_setup_fails( assert len(mock_setup_entry.mock_calls) == 0 -async def test_async_forward_entry_setup_deprecated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test async_forward_entry_setup is deprecated.""" - entry = MockConfigEntry( - domain="original", state=config_entries.ConfigEntryState.LOADED - ) - - mock_original_setup_entry = AsyncMock(return_value=True) - integration = mock_integration( - hass, MockModule("original", async_setup_entry=mock_original_setup_entry) - ) - - mock_setup = AsyncMock(return_value=False) - mock_setup_entry = AsyncMock() - mock_integration( - hass, - MockModule( - "forwarded", async_setup=mock_setup, async_setup_entry=mock_setup_entry - ), - ) - - entry_id = entry.entry_id - caplog.clear() - with patch.object(integration, "async_get_platforms"): - async with entry.setup_lock: - await hass.config_entries.async_forward_entry_setup(entry, "forwarded") - - assert ( - "Detected code that calls async_forward_entry_setup for integration, " - f"original with title: Mock Title and entry_id: {entry_id}, " - "which is deprecated, await async_forward_entry_setups instead. " - "This will stop working in Home Assistant 2025.6, please report this issue" - ) in caplog.text - - async def test_reauth_issue_flow_returns_abort( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -2262,7 +2226,7 @@ async def test_entry_subentry_no_context( @pytest.mark.parametrize( ("unique_id", "expected_result"), - [(None, does_not_raise()), ("test", pytest.raises(HomeAssistantError))], + [(None, does_not_raise()), ("test", pytest.raises(data_entry_flow.AbortFlow))], ) async def test_entry_subentry_duplicate( hass: HomeAssistant, @@ -5304,6 +5268,52 @@ async def test_async_abort_entries_match( assert result["reason"] == reason +@pytest.mark.parametrize( + ("matchers", "reason"), + [ + ({"host": "3.4.5.6", "ip": "1.2.3.4", "port": 23}, "no_match"), + ], +) +async def test_async_abort_entries_match_context( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + matchers: dict[str, str], + reason: str, +) -> None: + """Test aborting if matching config entries exist.""" + entry = MockConfigEntry( + domain="comp", data={"ip": "1.2.3.4", "host": "3.4.5.6", "port": 23} + ) + entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_reconfigure(self, user_input=None): + """Test user step.""" + self._async_abort_entries_match(matchers) + return self.async_abort(reason="no_match") + + with mock_config_flow("comp", TestFlow), mock_config_flow("invalid_flow", 5): + result = await manager.flow.async_init( + "comp", + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + @pytest.mark.parametrize( ("matchers", "reason"), [ @@ -7386,78 +7396,6 @@ async def test_non_awaited_async_forward_entry_setups( ) in caplog.text -async def test_non_awaited_async_forward_entry_setup( - hass: HomeAssistant, - manager: config_entries.ConfigEntries, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test async_forward_entry_setup not being awaited.""" - forward_event = asyncio.Event() - task: asyncio.Task | None = None - - async def mock_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry - ) -> bool: - """Mock setting up entry.""" - # Call async_forward_entry_setup without awaiting it - # This is not allowed and will raise a warning - nonlocal task - task = create_eager_task( - hass.config_entries.async_forward_entry_setup(entry, "light") - ) - return True - - async def mock_unload_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry - ) -> bool: - """Mock unloading an entry.""" - result = await hass.config_entries.async_unload_platforms(entry, ["light"]) - assert result - return result - - mock_remove_entry = AsyncMock(return_value=None) - - async def mock_setup_entry_platform( - hass: HomeAssistant, - entry: config_entries.ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, - ) -> None: - """Mock setting up platform.""" - await forward_event.wait() - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=mock_setup_entry, - async_unload_entry=mock_unload_entry, - async_remove_entry=mock_remove_entry, - ), - ) - mock_platform( - hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) - ) - mock_platform(hass, "test.config_flow", None) - - entry = MockConfigEntry(domain="test", entry_id="test2") - entry.add_to_manager(manager) - - # Setup entry - await manager.async_setup(entry.entry_id) - await hass.async_block_till_done() - forward_event.set() - await hass.async_block_till_done() - await task - - assert ( - "Detected code that calls async_forward_entry_setup for integration " - "test with title: Mock Title and entry_id: test2, during setup without " - "awaiting async_forward_entry_setup, which can cause the setup lock " - "to be released before the setup is done. This will stop working in " - "Home Assistant 2025.1, please report this issue" - ) in caplog.text - - async def test_config_entry_unloaded_during_platform_setup( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -7476,7 +7414,7 @@ async def test_config_entry_unloaded_during_platform_setup( def _late_setup(): nonlocal task task = asyncio.create_task( - hass.config_entries.async_forward_entry_setup(entry, "light") + hass.config_entries.async_forward_entry_setups(entry, ["light"]) ) hass.loop.call_soon(_late_setup) @@ -7527,7 +7465,7 @@ async def test_config_entry_unloaded_during_platform_setup( assert ( "OperationNotAllowed: The config entry 'Mock Title' (test) with " - "entry_id 'test2' cannot forward setup for light because it is " + "entry_id 'test2' cannot forward setup for ['light'] because it is " "in state ConfigEntryState.NOT_LOADED, but needs to be in the " "ConfigEntryState.LOADED state" ) in caplog.text @@ -7551,7 +7489,7 @@ async def test_config_entry_late_platform_setup( def _late_setup(): nonlocal task task = asyncio.create_task( - hass.config_entries.async_forward_entry_setup(entry, "light") + hass.config_entries.async_forward_entry_setups(entry, ["light"]) ) hass.loop.call_soon(_late_setup) diff --git a/tests/test_core_config.py b/tests/test_core_config.py index 2723c8e7196..bbf7027e7ef 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -5,7 +5,6 @@ from collections import OrderedDict import copy import os from pathlib import Path -import re from tempfile import TemporaryDirectory from typing import Any from unittest.mock import Mock, PropertyMock, patch @@ -833,7 +832,7 @@ async def test_configuration_legacy_template_is_removed(hass: HomeAssistant) -> }, ) - assert not getattr(hass.config, "legacy_templates") + assert not hass.config.legacy_templates async def test_config_defaults() -> None: @@ -1072,18 +1071,6 @@ async def test_debug_mode_defaults_to_off(hass: HomeAssistant) -> None: assert not hass.config.debug -async def test_set_time_zone_deprecated(hass: HomeAssistant) -> None: - """Test set_time_zone is deprecated.""" - with pytest.raises( - RuntimeError, - match=re.escape( - "Detected code that sets the time zone using set_time_zone instead of " - "async_set_time_zone. Please report this issue" - ), - ): - await hass.config.set_time_zone("America/New_York") - - async def test_core_config_schema_imperial_unit( hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 961afd69c2d..a5908f0feab 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -886,8 +886,8 @@ async def test_show_progress_fires_only_when_changed( ) # change (description placeholder) -async def test_abort_flow_exception(manager: MockFlowManager) -> None: - """Test that the AbortFlow exception works.""" +async def test_abort_flow_exception_step(manager: MockFlowManager) -> None: + """Test that the AbortFlow exception works in a step.""" @manager.mock_reg_handler("test") class TestFlow(data_entry_flow.FlowHandler): @@ -900,6 +900,33 @@ async def test_abort_flow_exception(manager: MockFlowManager) -> None: assert form["description_placeholders"] == {"placeholder": "yo"} +async def test_abort_flow_exception_finish_flow(hass: HomeAssistant) -> None: + """Test that the AbortFlow exception works when finishing a flow.""" + + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 1 + + async def async_step_init(self, input): + """Return init form with one input field 'count'.""" + return self.async_create_entry(title="init", data=input) + + class FlowManager(data_entry_flow.FlowManager): + async def async_create_flow(self, handler_key, *, context, data): + """Create a test flow.""" + return TestFlow() + + async def async_finish_flow(self, flow, result): + """Raise AbortFlow.""" + raise data_entry_flow.AbortFlow("mock-reason", {"placeholder": "yo"}) + + manager = FlowManager(hass) + + form = await manager.async_init("test") + assert form["type"] == data_entry_flow.FlowResultType.ABORT + assert form["reason"] == "mock-reason" + assert form["description_placeholders"] == {"placeholder": "yo"} + + async def test_init_unknown_flow(manager: MockFlowManager) -> None: """Test that UnknownFlow is raised when async_create_flow returns None.""" diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 191e1b7368c..9fcb84beec6 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -655,5 +655,5 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: ) as mock_process: await async_get_integration_with_requirements(hass, "comp") - assert len(mock_process.mock_calls) == 1 # dhcp does not depend on http + assert len(mock_process.mock_calls) == 2 # dhcp does not depend on http assert mock_process.mock_calls[0][1][1] == dhcp.requirements diff --git a/tests/testing_config/custom_components/test/camera.py b/tests/testing_config/custom_components/test/camera.py deleted file mode 100644 index b2aa1bbc53b..00000000000 --- a/tests/testing_config/custom_components/test/camera.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Provide a mock remote platform. - -Call init before using it in your tests to ensure clean test data. -""" - -from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities_callback: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Return mock entities.""" - async_add_entities_callback( - [AttrFrontendStreamTypeCamera(), PropertyFrontendStreamTypeCamera()] - ) - - -class AttrFrontendStreamTypeCamera(Camera): - """attr frontend stream type Camera.""" - - _attr_name = "attr frontend stream type" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - _attr_frontend_stream_type: StreamType = StreamType.WEB_RTC - - -class PropertyFrontendStreamTypeCamera(Camera): - """property frontend stream type Camera.""" - - _attr_name = "property frontend stream type" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the stream type of the camera.""" - return StreamType.WEB_RTC diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 883b17c733c..7d0eb7226a0 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -8,6 +8,8 @@ from itertools import chain import pytest from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -24,6 +26,7 @@ from homeassistant.const import ( UnitOfMass, UnitOfPower, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -47,8 +50,10 @@ from homeassistant.util.unit_conversion import ( EnergyDistanceConverter, InformationConverter, MassConverter, + MassVolumeConcentrationConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -67,6 +72,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { for converter in ( AreaConverter, BloodGlucoseConcentrationConverter, + MassVolumeConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -78,6 +84,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { MassConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -125,8 +132,18 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo ), InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8), MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473), + MassVolumeConcentrationConverter: ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + 1000, + ), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), PressureConverter: (UnitOfPressure.HPA, UnitOfPressure.INHG, 33.86389), + ReactiveEnergyConverter: ( + UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + 1000, + ), SpeedConverter: ( UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR, @@ -501,6 +518,18 @@ _CONVERTED_VALUE: dict[ 6.213712, UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, ), + ( + 10, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + 100, + UnitOfEnergyDistance.WATT_HOUR_PER_KM, + ), + ( + 15, + UnitOfEnergyDistance.WATT_HOUR_PER_KM, + 1.5, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + ), ( 25, UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, @@ -622,6 +651,20 @@ _CONVERTED_VALUE: dict[ (30, UnitOfPressure.MMHG, 1.181102, UnitOfPressure.INHG), (5, UnitOfPressure.BAR, 72.51887, UnitOfPressure.PSI), ], + ReactiveEnergyConverter: [ + ( + 5, + UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + 5000, + UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + ), + ( + 5, + UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + 0.005, + UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + ), + ], SpeedConverter: [ # 5 km/h / 1.609 km/mi = 3.10686 mi/h (5, UnitOfSpeed.KILOMETERS_PER_HOUR, 3.106856, UnitOfSpeed.MILES_PER_HOUR), @@ -704,6 +747,22 @@ _CONVERTED_VALUE: dict[ (5, None, 5000000, CONCENTRATION_PARTS_PER_MILLION), (5, PERCENTAGE, 0.05, None), ], + MassVolumeConcentrationConverter: [ + # 1000 µg/m³ = 1 mg/m³ + ( + 1000, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + 1, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + ), + # 2 mg/m³ = 2000 µg/m³ + ( + 2, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + 2000, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + ], VolumeConverter: [ (5, UnitOfVolume.LITERS, 1.32086, UnitOfVolume.GALLONS), (5, UnitOfVolume.GALLONS, 18.92706, UnitOfVolume.LITERS), diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index ddefe92de42..87a9729700e 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -434,6 +434,7 @@ def test_get_unit_system_invalid(key: str) -> None: UnitOfVolume.CUBIC_METERS, ), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), + (SensorDeviceClass.GAS, UnitOfVolume.LITERS, None), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, None), (SensorDeviceClass.GAS, "very_much", None), # Test precipitation conversion @@ -573,7 +574,10 @@ UNCONVERTED_UNITS_METRIC_SYSTEM = { UnitOfLength.METERS, UnitOfLength.MILLIMETERS, ), - SensorDeviceClass.GAS: (UnitOfVolume.CUBIC_METERS,), + SensorDeviceClass.GAS: ( + UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, + ), SensorDeviceClass.PRECIPITATION: ( UnitOfLength.CENTIMETERS, UnitOfLength.MILLIMETERS, @@ -687,6 +691,7 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: # Test gas meter conversion (SensorDeviceClass.GAS, UnitOfVolume.CENTUM_CUBIC_FEET, None), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), + (SensorDeviceClass.GAS, UnitOfVolume.LITERS, UnitOfVolume.CUBIC_FEET), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, None), (SensorDeviceClass.GAS, "very_much", None), # Test precipitation conversion